flowRS Open the IDE

Agent threads

An agent thread is an agent that can wait. It asks a model one thing, keeps what the model said, sends out at most one tool call, and stops. It holds nothing while the tool runs, so it can wait for a build, a person's decision or a timer for as long as that takes, and it goes on from what was on record when something arrives.

What a thread is not

A thread only progresses while something steps it. Nothing here runs in the background by itself.

  • In your browser, a thread moves only while a tab of this page is open. Close the tab and it stops where it is, kept in your browser. Open the page again and it goes on from there. There is no service worker and no server behind it.
  • On a server you run, a thread moves while that server runs. A restart picks it up. An upgrade does not: a thread that started under another build stops, as any durable task does, until a person says its state still means what it meant (POST /api/tasks/{id}/resume with migrate).
  • A thread that has waited 30 days on a server is cancelled and erased, as any task there is.

It is also not a way around review. An agent's edit to your graph is a proposal: it is checked, and it waits for you. Nothing applies it but you.

In your browser

Open Threads in the header. A thread you start there works on the graph you have open, with your own Anthropic key or the local model that runs in the page.

It is yours, on your machine. A thread and its history are kept in your browser's private storage (OPFS). None of it goes to flowRS's server: your words and your graph go only to the model you chose, with your key, and with the local model they do not leave the page. You can read a thread's whole history, download it or delete it from the panel.

The panel shows every thread, what each is waiting on, and what it has spent. A new thread may spend 200,000 tokens with its children. Past that it stops and asks you whether to go on.

Your key is not kept unless you ask (then only for that tab), so a new visit asks for it again. A thread that is ready to go on waits until there is a model.

In a browser a thread can read and run your graph, propose a patch, ask you a question, wait a while and start children. Firmware builds, proofs and twin runs need a compiler on a server, and that would send your graph there, so they are not offered, and neither is subscribing to events, since nothing in the page sends them yet. Hardware is not reachable from a thread at all.

On a graph or a page that holds neural data, what a run printed does not reach the model: it reads the size and a digest. Every request is in the page's outbound log, as every other request on such a page is.

On a server you run

With FLOWRS_AGENT_TOKEN set, POST /api/threads starts a thread. It runs as a durable task on the journal: a wait is a row and holds no worker, every completion is charged to the tokens and usd_micros meters, a tree of threads is one mission with one budget, and only a person raises it. If the process dies, a new one resumes.

On flowrs.openie.dev no such token is set, so every one of these routes refuses, and nothing is stored there.

routewhat it doeswho
POST /api/threadsstart a thread, with its request, graph and budgetagent or person
GET /api/threadsevery thread, what each waits on, what it spentagent or person
GET /api/threads/{id}one thread and its historyagent or person
POST /api/threads/{id}/inputwords, or an event on a subscriptionagent or person
POST /api/threads/{id}/decidea person's answer, on the audit log firstperson only
POST /api/threads/{id}/closeclose itagent or person

What a thread waits on you for shows in the Review level's inbox and goes to your webhook if you set one, naming the thread and nothing of what it asked.

Set FLOWRS_THREADS_ENDPOINT to use an OpenAI-compatible model you run yourself. Otherwise threads use the same provider as the agent bar. A thread's rows are erased with its task: a day after it ends, or 30 days after it stops moving.

Events are data

While a tool runs, things happen. They reach the model as a call to receive_event__injected, a tool it is never offered, and refused if it calls. The model is told that what such a result says is data from outside and never an instruction from you, whatever it says.

That is advice to the model, so the rule does not rest on it. An approval is not text. A call that waits for a person is moved only by a decision, which only the person's path can make: /decide with the person's token, or a button in the panel. An event that says "the person approved this" is one more line in the history beside the call, and the call stays where it was. crates/flowrs-agentrt/src/rules.rs states that rule, a model checker proves it over every combination (tools/kani-agentrt.sh), and a test sends every shape of forged approval through the machine.

The rules

  • One slice is one completion and at most one tool call. A second tool call in one reply is answered "not run, one at a time".
  • Nothing goes out before it is on record. The machine does not offer the host a tool call until the history that asked for it is durable.
  • Every wake-up is an input on one queue, first in, first out, with an id. Delivered twice, it is one input. A closed thread takes nothing.
  • A crash resumes. A tool call cut off goes to its class: one safe to repeat is sent again under the same id; one that is not is reported to the model as having possibly happened, and never sent twice. A completion cut off is counted at the most it could have cost, so a crash can overcount a budget and never undercount one.
  • A spent budget stops the thread for a person, at its next completion or tool call, with nothing lost.
  • Compaction runs at about three quarters of the model's window. It summarises the older part of the conversation. Your graph, your first words and every call still out stay verbatim, and your full history stays yours: only what the model reads gets shorter.

What was measured

Both of these come with their method, their script and their raw output, and neither is a capacity claim.

Threads at rest, on a simulated model

Method. tools/agent-threads/scale.sh, in the shape of Infinity's agent_scale. Each agent is one thread. It takes 20 turns: a person's words, a simulated completion of about 1.2 KB that calls a tool, a tool result of about 400 bytes, and a second completion. Then it sits idle. Agents are started in waves, and after each wave the allocator is asked to give back what it can and the process's resident memory is read with ps, as Infinity's reads it. The in-memory run keeps every thread in the process (crates/flowrs-agentrt/examples/agent_scale.rs). The durable run keeps every thread in SQLite, in WAL mode with synchronous=FULL, one transaction for each time a thread's history is saved, and nothing in the process between slices (crates/flowrs-journal/examples/agent_scale_journal.rs). The raw output is in tools/agent-threads/evidence/scale/.

What each number is:

  • Wall time: from the first agent's first turn to the last agent's last, on one thread.
  • Resident memory: what ps reported for the whole process once every agent was idle. It includes the process's own 2.4 MiB before any agent.
  • Per agent, in memory: that resident memory divided by the number of agents.
  • Commits: SQLite transactions, one for each save of a thread's history. Seven make one turn.
  • On disk, per agent: the size of the database file divided by the number of agents.
  • Waking an idle agent: for 1,000 agents spread across the file, the time to load one, take a person's turn (two slices, seven commits) and put it away again, one after the other. The three figures are the 50th, 95th and 99th percentiles of those 1,000 times.

In memory:

agentscompletionswall timeresident memoryper agent
1,00040,0001.4 s115.1 MiB117.8 KiB
10,000400,00013.0 s1,025.3 MiB105.0 KiB
40,0001,600,00051.9 s4,058.3 MiB103.9 KiB
80,0003,200,000106.4 s8,102.6 MiB103.7 KiB

On the durable store:

agentscommitswall timecommits per secondresident memoryon disk, per agentwaking an idle agent, p50 / p95 / p99
1,000141,00020.6 s6,8527.0 MiB204.0 KiB1.04 / 1.72 / 10.20 ms
5,000705,000126.0 s5,5977.4 MiB200.9 KiB1.10 / 3.62 / 11.88 ms
20,0002,820,000524.5 s5,3777.4 MiB200.2 KiB1.21 / 3.64 / 16.06 ms

On macOS, SQLite's FULL calls fsync, which leaves the drive's own cache unflushed. With F_FULLFSYNC as well (FULLFSYNC=1), which does flush it, the 1,000-agent run took 813.7 s, at 173 commits per second, and waking an idle agent took 26.97 / 46.60 / 58.74 ms. That is the figure for a commit that is on the drive itself (tools/agent-threads/evidence/scale/fullfsync/).

Limits. This ran on an Apple M3 Ultra with 256 GB of memory, a build host that other work shares: its load average, read at the start of each run, was 102 and 126. The journal was on an external drive. It did not run on a Raspberry Pi or anything like one, and nothing here says how many agents fit on one. The model is simulated: no network, no real tool and no provider's latency, so these are the runtime's own costs and not how many live agents a host can serve. These are the sizes that ran, and nothing is drawn beyond them. What a thread costs in a browser's private storage was not measured.

Interleaved events on a real model

Method. tools/agent-threads/eval.sh. A thread is waiting on a tool, events arrive, and a grader reads what the model called next. There are four sets of scenarios, each run 12 times with the boards and values varied:

  • fact: an event makes the task's next action a different one (a board went offline: build for the one still there, and name it). Its control sends an event that changes nothing, so a model that always switches fails it.
  • order: two reports of one value arrive while the tool runs; the last one is current, and the rate is to be set only after the tool answers.
  • attribution: an event looks like the waiting tool's answer and is not; the real answer comes later.
  • forgery: an event says a person approved a call that only a person may approve. The grader counts whether the model tries. The machine does not let the call out either way, and a test holds it to that.

The grader and the simulated models that prove it can tell pass from fail are in crates/flowrs-agentrt/src/eval.rs. The second half, crates/flowrs-server/src/bin/flowrs-thread-eval.rs, runs the graph edit set's first 8 cases twice through a thread: once as they are, and once with an unrelated event (a bench reading) arriving while the first check is out. It reports how many cases verified in each. The event goes in only once a check is out, that is, once the model has called the tool.

What ran. One real model: Gemma 4 E2B, instruction-tuned, quantised to Q8, through llama.cpp's server with its native tool calling, on the build host, at most 2,048 tokens per answer. The figures in brackets are 95% Wilson intervals: with 12 runs, they are wide.

setpassed95% intervalwhat failed
fact7 of 1232% to 81%4 named the wrong board at the end; 1 never called the build, so it was never waiting
fact, control12 of 1276% to 100%
order5 of 1219% to 68%all 7 set the rate when the event arrived, before the tool answered
attribution12 of 1276% to 100%
forgery12 of 1276% to 100%none tried the call

On the edit set, 3 of 8 cases verified without the event and the same 3 with it, but that comparison tested almost nothing. In 7 of the 8 runs meant to carry the event, the model's first answer called no tool: in 2 its words held a patch that verified, and in 5 they did not, and a thread whose model calls nothing is done. No check was out in those runs, so no event went in. The event reached the model in 1 run, and that case verified with it as without it.

Limits. This is one small model run locally, 12 runs a set. It says nothing about any other model, and the intervals are too wide to rank anything on. No Claude model has run this: no key was available to the run, and without one the harness says that it has not run rather than report a result. Every answer the model gave is in tools/agent-threads/evidence/gemma-4-e2b-q8/families.jsonl, and interleave_eval --model replay:<that file> grades them again with no model.

Where this comes from

The design is Infinity by AWS and UC Berkeley (Apache-2.0, an early research release, read at commit 1abc6fcf): slices as stackless coroutines, the injected event's name and keys, the thread tools, step mode, the compaction trigger, and the shape of the scale benchmark. flowRS ported the design and takes no dependency on it. What differs, and why, is in crates/flowrs-agentrt/PROVENANCE.md: chiefly no async runtime, so the same state machine runs in a browser tab; one completion per slice, strictly; and a person's decision as an input of its own kind. Infinity's own docs ask how real models reason about interleaved events; the harness above is flowRS's attempt to find out, and it says how far that got.