Docs

Screen flows as REST APIs

A flow is a tested procedure on a host screen, written once by someone who knows the screen. On the gateway (WND Terminal in a browser, see gateway configuration) every flow can also be an HTTPS endpoint. A program sends the inputs as JSON and gets the outputs back as JSON, typed. The gateway describes all of them in an OpenAPI 3.1 document that Postman, Insomnia and code generators import. Power Automate imports the same flows as Swagger 2.0, from /api/v1/openapi.json?format=swagger2. The host's programs don't change.

Download the product examples: API scripts, Power Query, OData flows, test CI files and Power BI connector source. The gateway archive includes its full deployment README.

curl -H "Authorization: Bearer $TOKEN" -d '{"customer":"12345678"}' https://terminal.bank.example/api/v1/flows/customer-lookup
{"ok":true,"flow":"customer-lookup","outputs":{"name":"ACME TRADING CO","status":"ACTIVE","opened":"03/14/2019","opened_iso":"2019-03-14","balance":12480.55},"ms":812,"run":"r_8fFUana0gY1kP7hD"}

Your license needs to include Screen flows as APIs.

Turn it on

In the gateway's configuration:

[api]
enabled = true
flows_folder = "/srv/wnd/api-flows"
connection = "Production (CICS)"

Put your flows (.wndflow files) in flows_folder. Flows in the policy's AgentFlowFolders are served too. The gateway looks at the folders every 10 seconds and reads them again when a file changes, so a new or changed flow is served without a restart. A file it can't read is in the gateway's log (api.flows) and in /api/v1/health, with the line and what's wrong.

Each flow runs in a connection from the policy. A flow names its own with a #! connection: Production (CICS) line. One without that line runs in [api] connection. The rest of the settings are in gateway configuration.

Write a flow

A flow is a WND Terminal macro (macros.md) with a header. The header is the API's contract:

#! name: customer-lookup
#! description: Looks up a customer by their 8-digit number: name, status, when the account was opened, and the balance.
#! input customer "The 8-digit customer number" pattern [0-9]{8}
#! output name "The customer's name"
#! output status "ACTIVE, CLOSED or HOLD"
#! output opened "When the account was opened" date
#! output balance "The balance" number
#! writes: no
#! session: pooled
#! reset: back-to-inquiry
#! timeout: 30
wait "Type a customer number" 10
move 6 18
type {customer}
press Enter
wait "STATUS:" 10
if screen has "NOT FOUND"
  fail "No customer has the number " & customer
end
read name 8 10 30
read status 9 10 10
read opened 10 10 10
read balance 11 10 20
  • The name is the endpoint: POST /api/v1/flows/customer-lookup.
  • Each input is a property of the JSON body. A pattern is checked before anything is sent to the host, and a call that doesn't fit is answered 400 with the input's own description. optional and default "US" make an input optional.
  • Each output comes back typed. number gives a JSON number (thousands commas and currency signs dropped, a trailing minus, CR or DB makes it negative). date gives the text as the screen shows it and NAME_iso as YYYY-MM-DD, read in the order #! dates: says (mdy unless it says dmy or ymd). yesno gives true or false. text is the default.
  • writes says whether the flow changes data on the host. It decides what the API does with it (below).
  • timeout is how long the whole run may take, in seconds (120 unless it says).

Write a flow for programs the way you'd want a colleague to work an unfamiliar screen:

  • Always wait for the screen you expect before you type. wait "Type a customer number" 10 fails fast and clearly when the session isn't where the flow thinks it is.
  • Check what you can. assert row 1 has "CUSTOMER INQUIRY" "Not on the inquiry screen" turns a wrong screen into an answer that says so, instead of outputs read from the wrong place.
  • Say "not found" on purpose. When the host says a customer doesn't exist, fail with your words. The caller gets a 422 with your message and the line. That's an answer, not an error, and it's easy for a program to handle.
  • Read the outputs last, after the screen that has them, and give each one a type.

Try a flow before it goes on the gateway. With WND Terminal open on the screen it starts from:

wnd-automate flow run customer-lookup.wndflow --input customer=12345678
customer-lookup: done in 812 ms (9 steps, 2 screens)
  balance     12480.55
  name        ACME TRADING CO
  opened      03/14/2019
  opened_iso  2019-03-14
  status      ACTIVE
Steps:
  line  12     102 ms  wait "Type a customer number" 10
  ...

It runs the flow through the desktop app's automation API (see automation-api.md), in the first open session or the one --session names. It's read-only key by key unless the flow says writes: yes, as on the gateway. When the run fails it says where, why, and shows the screen then, with sensitive values hidden. --json prints the whole outcome: each line's time, the screens it went through, the failure.

Sessions

How a flow gets its session is in its header.

#! session: fresh (the default). Each call opens a session, signs on (PassTicket sign-on works as for people and agents, see gateway configuration), runs the flow, and closes the session. Simple, and nothing carries over from one call to the next.

#! session: pooled with #! reset: back-to-inquiry. The gateway keeps sessions open between calls. A call takes an idle session, runs the flow, and the session goes back to its start screen with the reset macro, ready for the next call. That saves the sign-on and the screens before the start screen, which is most of the time a short flow takes. The reset macro is a plain macro in the flows folder, named as the header says, with .wndmacro or .txt after it (back-to-inquiry.wndmacro):

press PF3
wait "Type a customer number" 10

Rules for a reset macro:

  • End with a wait for something only the start screen has. That's how the gateway knows the session is ready.
  • Keep it to moving around (PF3, Clear, a menu choice). It runs with no inputs.
  • Make it work from every screen the flow can end on, including after a fail.

When a run goes wrong (a timeout, the host closing, a mistake in the macro) or the reset doesn't reach the start screen, the gateway closes that session and never uses it again. The next call opens a new one. A run that ends with the flow's own fail or assert is a normal answer, so the reset runs and the session stays in the pool when the reset works. Without a reset line, a pooled session's next call starts wherever the last one ended.

Pooled sessions are shared only by flows that start on the same screen: flows with the same reset macro share their sessions, and a flow without a reset line has sessions of its own. A session one flow left on its start screen is never handed to a flow that starts somewhere else.

Limits. Each caller (a person, or a service account) has its own sessions for each connection and start screen, at most max_sessions_per_connection (4). When they're all busy a call waits, at most queue_timeout_seconds (30), with at most max_queue (50) calls waiting. Past that it's answered 503 with Retry-After. An idle pooled session closes after pool_idle_minutes (15). Each run is limited to the flow's timeout. The person's own session limits ([limits] max_sessions_per_user) count API sessions too.

Every session the API opens is recorded, whatever the recording policy says, named with the person or service account. A person's API sessions show in their Your open sessions with an "API" badge.

Who can call

Each request has Authorization: Bearer with a token, as for AI agents on the gateway (see mcp.md):

  • A service account's token. For a program that works on its own. wnd-gateway new-agent-token NAME makes the token and the [mcp.service_accounts.NAME] lines. A service account's connections, mode and sandbox_only apply to its API calls too.
  • A person's token from your identity provider. The flow runs as that person, in their sessions, with their limits, and the audit log has it under their name.

Requests from another site's web page are refused, so a page someone opens can't call the API with their browser. The form page (below) is the one part of the API for people in a browser.

What may run

A flow with writes: no runs in every mode, and each key it presses is still checked. A key the screen says could change data stops the run (409, kind read_only).

A flow with writes: yes follows [api] mode, the policy's AgentActionsMode and the service account's own mode. The stricter one wins.

On a small screen, swipe the table to read all columns.

Mode A flow that changes data
readonly (the default) Refused with 409, kind read_only. Nothing is sent to the host.
ask The call answers 202 at once. The change waits for an approver, then runs.
allow Runs.

Approvals

In ask mode the gateway takes a session, puts the change in the approvals queue and answers:

HTTP/1.1 202 Accepted
Location: /api/v1/runs/r_30YiUxQWAuIppA2g

{"ok":true,"status":"waiting_approval","flow":"update-customer","run":"r_30YiUxQWAuIppA2g","approval":17}

People in [mcp] approvers_group see it at /approvals, as they see agents' changes: who it's for, the flow and its inputs (sensitive values masked), and the screen as it is, masked. When it's approved the flow runs in the session that was kept for it. GET the Location for the result. It answers 202 while the run waits or runs, then what the call would have answered (200, 422, ...). A denied run, or one nobody answered within approval_timeout_seconds, is answered 403 with "status":"denied". Without an approvers group, a person approves their own calls. A service account's calls need an approvers group.

Answers

On a small screen, swipe the table to read all columns.

Status When Body
200 The flow ran. {"ok":true,"flow":"…","outputs":{…typed…},"ms":812,"run":"r_…"}
202 A flow that changes data waits for an approver (ask mode). {"status":"waiting_approval","run":"r_…","approval":17}, Location: /api/v1/runs/r_…
202 A flow that changes data didn't reach the approvals queue in time, because it was still waiting for a session. GET the Location for the result. {"status":"running","run":"r_…"}, Location: /api/v1/runs/r_…
400 An input is missing, doesn't fit its pattern, or isn't one of the flow's. The body isn't a JSON object. {"ok":false,"error":{"kind":"bad_input","message":"customer \"12\" doesn't fit: The 8-digit customer number."}}
401 No token, or one the gateway doesn't accept. kind unauthorized, with WWW-Authenticate: Bearer
403 Another site's page, a license without Screen flows as APIs, a connection the service account may not use, or an approver said no. kind forbidden, license, refused or denied
404 No flow by that name (or the API isn't turned on). kind not_found
409 The flow changes data and the mode is readonly, or a read-only flow pressed a key that could change data. An Idempotency-Key in use. kind read_only, in_progress or idempotency_key_reused
422 The flow ended with its own fail, or an assert wasn't true. kind fail or assert, such as {"ok":false,"error":{"kind":"fail","line":14,"message":"No customer has the number 99999999"},"ms":…,"run":"r_…"}
429 Too many wrong tokens from this address. Wait a minute, then try again. kind busy
500 The host went wrong, or the macro has a mistake. kind host_error or script, with the line and the screen then, sensitive values hidden
503 Every session is busy and the queue is full, or a call waited queue_timeout_seconds. kind busy, with Retry-After: 5
504 The host didn't show what the flow waited for within its timeout. kind timeout

Every answer from a run has its run ID. GET /api/v1/runs/{id} gives it again for an hour, to the caller who made it.

Idempotency keys

Send Idempotency-Key with any unique text (a UUID) on every call of a flow that changes data. A call with the same key within 24 hours gets the same answer again, with Idempotent-Replayed: true, and the flow doesn't run again. So a program that lost the answer (a timeout, a dropped connection) can send the call again safely. The same key with other inputs is answered 409, kind idempotency_key_reused. A call answered 503 can be sent again with its key.

Inputs that don't fit are answered 400 before an Idempotency-Key is kept, so nothing is kept for that call. Each caller keeps at most 1,000 keys and run results. When a caller goes past that, their oldest finished ones go first.

The OpenAPI document

GET /api/v1/openapi.json (with a token) is an OpenAPI 3.1 document made from the flows served now:

  • one POST /flows/{name} operation for each flow, its operation ID the name with underscores (customer_lookup), its description the flow's;
  • the request body from the inputs, with their descriptions, patterns and defaults;
  • the 200 answer from the outputs, typed (a date with its _iso);
  • every error answer above, the 202 for flows that change data, and the Idempotency-Key header;
  • x-wnd-writes, x-wnd-connection and x-wnd-session on each operation, and a tag for each connection;
  • servers from [api] public_url, the title from [api] title ("your company mainframe API" from the license unless you set one), and bearer authentication.

Power Automate's, Power Apps' and Logic Apps' custom connectors import Swagger 2.0, so the gateway also gives the same flows in that form at /api/v1/openapi.json?format=swagger2. Each flow becomes an action, with the token as an API key in the Authorization header (Bearer and the token).

The document has an ETag, and changes when a flow file does. GET /api/v1/flows lists the same flows as JSON, and GET /api/v1/health gives the flows loaded, the problems, and the sessions by connection (idle, busy, broken, waiting, dropped).

examples/api has curl.sh, python.py, a Postman collection, and how to make a Power Automate custom connector from the document (powerautomate.md).

The form page

https://<gateway>/api/v1/flows/customer-lookup/form is the flow as a web page: its inputs as fields, a Run button, and the outputs as a table. Anyone signed in to WND Terminal in the browser can use it, with no terminal and nothing to install. A flow becomes a small application for people who don't know the screens. Add ?screen=modern and the page also shows the last screen as a form, the way Modern view shows it in WND Terminal.

The page runs the flow as the person who is signed in, with the same rules, approvals and audit as the API. A program with a token gets 404 here. It uses the API instead.

The sandbox

With [mcp] sandbox_only (or a service account's sandbox_only) the API's sessions play recordings from [mcp] sandbox_folder on replay hosts on the gateway's own computer (see replay-host.md), never a real host. The flows in that folder are served too. A flow plays the recording its #! replay: name.wndrec line names, or the one named like its connection, or the folder's only recording.

examples/api is a sandbox that works as it is. Point sandbox_folder at it and call customer-lookup (examples/api/gateway.toml has the lines). Its recording goes round the customer inquiry many times, so the pooled session and its reset macro work as they would on a host.

What's recorded

  • The audit log. api.call for each call (the flow, the caller, the outcome, how long it took, the run ID), api.refused with the reason, and api.approval_requested, with "via": "api" and the program's address. Never the inputs or the outputs. The flow's own run is agent.flow, and approvals are agent.approved and agent.denied, as for agents. The same events go to the gateway's log for your SIEM.
  • Recordings. Every session the API opens, as above.

What leaves the gateway goes through masking, with one deliberate exception. The outputs are given to the caller as the host shows them, because an API that hides the balance it was asked for is no use. The caller is authenticated, and the flow decides what it reads. Password (hidden) fields can never be read by a flow. Your MaskPatterns, and card numbers and similar values, are still hidden in the screens of error answers, on the approvals page, in the logs and in the form page's screen.

Try it on your own screens

Start a team trial or ask us to plan a proof of value.