Agent runtimes over RAP
flowRS's tools can be called over the Reactive Agent Protocol (RAP), from a runtime that does not keep a connection open. A firmware build, a twin run or a change waiting for a person to approve it can take minutes or days. Over RAP the runtime asks once, hears 200 at once, and can go away: when the work ends, flowRS sends the answer to an address the runtime gave it.
RAP was written by the authors of Infinity (Apache-2.0). Its specification is theirs. flowRS contains none of their code. We test against their crates from a separate project, tools/rap-interop, which does not ship with flowRS.
This does not work on flowrs.openie.dev. It needs a secret token and a list of addresses that only an operator can set, and neither is set there, so every route answers with a refusal and keeps nothing. It is for a server you run yourself.
Turn it on
FLOWRS_AGENT_TOKEN=<at least 16 characters>
FLOWRS_JOURNAL=/path/to/journal.db
FLOWRS_AUDIT_LOG=/path/to/audit.jsonl
FLOWRS_RAP_CALLBACK_ORIGINS=https://runtime.example:8443
| Setting | What it does |
|---|---|
FLOWRS_AGENT_TOKEN | The token a runtime sends, in Authorization: Bearer or x-flowrs-agent-token. The same one /mcp takes. |
FLOWRS_JOURNAL | Where tasks are kept. Without it RAP refuses, because an answer could not survive a restart. |
FLOWRS_RAP_CALLBACK_ORIGINS | The only origins an answer may go to, comma-separated: scheme, host and port. Without it RAP refuses. |
FLOWRS_RAP_CALLBACK_ADDRS | Private addresses a name on that list may resolve to, comma-separated. |
FLOWRS_RAP_ATTEMPTS | How many times one callback is tried. 8 by default. |
FLOWRS_RAP_MAX_RESULT_BYTES | The largest callback. 1 MiB by default. A larger result is cut and says so. |
FLOWRS_RAP_SUBSCRIPTIONS | The most subscriptions at once. 8 by default. |
FLOWRS_MCP_HTTP_PROFILE | Which tools are offered: plan by default, or full, neuro, critic, off. The same setting as /mcp. |
A setting the server cannot read stops it at startup, by name.
Point a runtime at it
The server's base URL is https://your-server/rap. A runtime reads the tools from /rap/.well-known/rap-toolset, with the token, and calls them at /rap/invoke.
{
"operation": "workspace_open",
"arguments": {},
"id": "call_1",
"call_id": null,
"callback_url": "https://runtime.example:8443/callback?token=yours",
"group_id": "thread_1"
}
The answer is 200 with an empty body. Later, flowRS POSTs this to the callback:
{
"type": "tool_result",
"group_id": "thread_1",
"id": "call_1",
"text": "{ \"workspace\": \"ws-...\", ... }"
}
As over /mcp, your tools work in a workspace: call workspace_open first and pass the handle as workspace. A workspace lives in memory and does not survive a restart, but a lease does. Keep the lease handle session_acquire gives you, and workspace_open with it comes back as the same holder.
A failed call arrives the same way, with its text starting Error: . An invocation that was not accepted gets a status other than 200 and no callback, because there is no callback to trust. Those are the refusals below, and a full queue (429).
When an answer arrives
Each invocation is a durable task. The same group_id and id sent again is the same task, and answers once.
When the task ends, however it ends, its answer is sent. If the callback answers 5xx, 408, 425 or 429, or the connection fails, it is tried again after 10 s, 30 s, 2 min, 10 min, 1 h, then every 6 h. The retries are rows in the journal, so they carry on across a restart and an upgrade. Delivery is at least once. A delivery can arrive twice if the server stops between sending it and writing down that it did, so treat a repeated id as the same answer.
A 3xx or any other 4xx ends the delivery at once. A delivery that runs out of attempts is given up on. Both are in the audit log.
Once an answer is delivered or given up on, what the task was asked and what it answered are erased. That it ran stays.
Subscriptions
These go beyond /mcp's tools. Each is answered first with a tool_result carrying "subscription": true, and then with subscription_event messages.
| Operation | What it sends | Profile |
|---|---|---|
rap_watch_task | A task's state and phase as they change, and a last event when it ends. Give task, or call (an earlier tool call in this thread). Never what the task answered or why it failed: read the task for those. | all |
rap_watch_twin | Firmware running on a twin, as waveform events: symbols, rate_hz, seconds, chunk_points, and demo: true or elf_base64. Each event has every series' extremes and at most 64 of its points. | full, plan |
rap_watch_limits | A limit crossing: workspace, model, measurement, channel (from 0), low, high. Only on a simulated instrument, connected with mock: true. Every reading is an audit entry, as any instrument read is. | full |
rap_watch_lsl | Which LSL streams are open, whether each is flowing or being recorded, and how many samples went by. Never a sample. Needs FLOWRS_LSL_GATEWAY. | full, plan, neuro |
Every event's text is JSON with a seq (counting every event made) and a dropped (how many this subscription has lost). A subscription holds at most 32 undelivered events. Past that its oldest goes and is counted, so a slow callback costs that subscription its events and nothing else. An event carries no identity of its own in RAP, so seq is also how you tell a repeat from a new one.
The last event has "final": true. A subscription lasts a day unless duration_s says otherwise, 30 days at most. It survives a restart, except that a twin's run is cut off by one and a limit watch's workspace is lost, and the last event says so. /cancel_tool_call stops it, and no event follows.
/close_thread cancels the thread's calls and subscriptions, sends it nothing more, and closes the workspaces it opened.
What is refused
callback_url is an address this server sends a request to, so it gets the most care.
| Refused | Answer |
|---|---|
| No agent token set on this server, a missing or wrong token | 401 |
| The person's token, even when it is right. A runtime is driven by a model, and a model must never hold the secret that approves what it asks for. | 403 |
A request from a web page (Origin), unless FLOWRS_MCP_HTTP_ORIGINS lists it | 403 |
A Host this server does not answer for | 421 |
No FLOWRS_RAP_CALLBACK_ORIGINS | 403 |
| No journal | 503 |
A callback whose scheme, host and port are not on the list, compared as parsed: https://listed.example.evil.test does not pass | 403 |
A callback that is not http or https, or carries a user name, a password or a fragment | 400 |
A listed name resolving to a loopback, link-local, private, shared (tailnet or NAT), reserved or cloud-metadata address, by any of its addresses, unless FLOWRS_RAP_CALLBACK_ADDRS names it | 403 |
| Identifiers, or a callback URL, past their limits (256 bytes, 2048 bytes) | 400 |
A body past FLOWRS_RATE_AGENT_MAX_BODY_BYTES | 413 |
Another invocation under an id still in use in that thread | 409 |
An address you list as an IP literal counts as named. http://127.0.0.1:9000 on the list is allowed, and only that.
Past the list, a name is resolved once for each attempt and the request goes to that address, with no second lookup. No redirect is followed, no proxy is used, and the response's body is never read. The token never leaves with a callback. Every delivery is in the audit log first, by its origin, its size and its SHA-256, and if the log cannot be written nothing is sent. A callback's path and query, which may hold your runtime's secret, are in no log, no listing and no error.
Hardware. RAP is another door to the same gates. A hosted workspace reaches only twins and mock instruments, as it does over /mcp. A call that changes an instrument files a proposal, and its result waits, for days if need be, until a person decides in the Review level. There is no way to approve anything through RAP: the agent's tools refuse it, the person's token is refused here, the server never reads what comes back from a callback, and it sends no user_choice a model could answer.
Neural data. A value derived from neural data is withheld from a tool's result as it is over /mcp, unless a person released it. The subscriptions carry shapes and counts, never values.
What is left to you. The list is the defence. A listed origin receives whatever the agent's tools answer, and a model holding the token chooses which listed origin, so list only runtimes that may see those answers. http:// on the list sends results in the clear.
What of RAP this is
Tested against Infinity's own rap-protocol, rap-client, infinity-rap-bridge and infinity-agent-core 0.2.0, with a simulated model: toolset discovery, invocation, tool_result, subscription_event with associative and final, cancel_subscription reaching /cancel_tool_call, and /close_thread. Run it with tools/rap-interop/run.sh.
Not implemented: content (images in a result), display_as, view_update, oauth, user_choice, displayScript, and /migrate (needsMigration is false). user_id and thread_ancestors are accepted and not read or kept.
No real model has driven this. flowRS has no RAP client: a flowRS agent cannot yet call someone else's RAP tool.