Features
Solutions
Who uses Directual and why?
What can be built on the platform?
What industries benefit from Directual?
Learn
The "model → tools → model" agent loop is awkward to build out of scenario steps: retries, streaming, limits and token accounting quickly turn it into a mess. Here is a setup where the loop runs in theloop, an open-source stateless HTTP service, while Directual stays the orchestrator and owns history, data, permissions, approvals and billing.

In our piece on 10 myths about AI agents we said that an LLM is an executor, and to make it reliable you have to embed it in a system. In "AI Agents: Not a Trend — a Necessity" we listed the infrastructure that takes. Today we show how to do it in practice: the agent reads your data, proposes actions and waits for the user to approve them. And we won't be building the agent loop itself in Directual.
This isn't a thought experiment: the setup below already runs in production for one of our clients.
A chatbot sends the model a question and shows the answer — that's what a basic ChatGPT integration with Directual looks like. An agent is more involved: the model can ask to call a tool ("find order 12345"), get the result, ask for another one, and only then answer. That's the loop: model → tools → model → … → final answer.
You can technically build this loop in a Directual scenario: an HTTP request to the provider, a condition "are there tool_calls", another HTTP request, a jump back. In a demo it even works. In production everything else shows up:
That's work for an engine, not an orchestrator. We moved it into a separate open-source service, theloop.
theloop is the agent loop as a stateless HTTP service (Apache 2.0, Node 22). One POST /runs request is one run. The request carries everything: provider, model, key, system prompt, the full conversation history, tools (URL + JSON schema) and limits. The engine runs the loop to the final answer, streams text over SSE, and sends the result plus exact token usage to a callback. Between calls it remembers nothing.
That's why it fits Directual so well. Everything that needs storage stays with you: history, assistants, prompts, keys, operations, approvals, money. In theloop terms Directual is the harness, the orchestrator. The split rule is simple:
Anything solved by storing data or calling an endpoint goes to Directual. Anything that needs a loop, a stream or a timer goes to theloop.
Providers out of the box: Anthropic, OpenAI, DeepSeek, GLM, GigaChat, MiniMax, Kimi and any OpenAI-compatible endpoint. You can pass a fallback chain: if the primary model fails before it starts answering, the engine moves on to the next one.
Frontend ──writes──► raw_chat_message
▲ │
│ socket: ▼
│ stream_url ┌──────────────── Directual ────────────────┐
│ │ S1 "Start run" │
│ │ collect history, prompt, tools │
│ │ HTTP request: POST /runs ───────────────┼──► theloop
│ │ │ │
│ │ Tool API endpoints ◄──────────────────────┼───────┤ tool calls
│ │ read: return data │ │
│ │ write: register an operation │ │
│ │ │ │
│ │ Callback API endpoint ◄───────────────────┼───────┘ result
│ │ store messages, usage, status │
│ └───────────────────────────────────────────┘
│
└──── SSE via stream_url (text as it's generated) ──── theloop
Directual stays out of the loop: it starts a run, answers tool calls like any other API, and accepts the result.
You need one always-on container with a public HTTPS address:
docker run -p 8080:8080 \
-e ENGINE_TOKEN=long-random-token \
-e CALLBACK_SECRET=another-random-secret \
-e PUBLIC_URL=https://loop.example.com \
-e EGRESS_ALLOWLIST=api.directual.com \
-e ALLOWED_ORIGINS=https://app.example.com \
-e ENGINE_LOCALE=en \
-e DEFAULT_TIMEZONE=Europe/London \
ghcr.io/paveler/theloop:1
What matters here:
ENGINE_TOKEN is how Directual authenticates to the engine. Keep it in your app secrets and never send it to the frontend: the browser talks to the engine with one-time run tokens.EGRESS_ALLOWLIST: the engine only calls allowed hosts, HTTPS only, no redirects, no private IPs. Tools and the callback live in the Directual API, so we allow api.directual.com.CALLBACK_SECRET: the engine puts it in the X-Engine-Secret header of every callback. That's how Directual tells a real result from a forged one.ENGINE_LOCALE and DEFAULT_TIMEZONE: by default the engine writes service strings in English and keeps time in UTC. If your users are in another language or time zone, set both — otherwise the model sees the date in the wrong zone and users get error messages in the wrong language.GET /health shows how many runs are in flight right now.Provider keys can be set in env (ANTHROPIC_API_KEY=…) or passed from Directual with every request. The latter is handy when your clients bring their own keys.
Reference data, which configures the assistant:
| Structure | Key fields |
|---|---|
assistant |
provider, model, api_key (secret), system_prompt, params, limits, pricing, tools (links) |
tool |
id (name the model sees), description, input_schema (json), endpoint, headers (secret), effect (read / write), enabled |
This is probably the biggest win of the pairing. Model, prompt and tool set are data, not code. Want to compare Claude and GigaChat on the same conversation? Change two fields in the assistant record, no redeploy. A new tool is a new tool record plus an endpoint.
Working structures, which live inside the conversation:
| Structure | Key fields | Purpose |
|---|---|---|
raw_chat_message |
session, text, response fields stream_url, cancel_url, error |
the frontend writes here; it's the user's intent, not history |
chat_session |
state (idle / running / awaiting_tool), active_run, history_revision |
the conversation and its state |
chat_message |
id, session, payload (json), role, seq, created_at |
history in the engine's format |
run |
id = run_id, status, deadline_at, result (json), usage fields, cost |
run results and billing |
The key field is chat_message.payload. It holds the message in theloop's format (StoredMessage) whole and unchanged: user turns, model replies, tool calls and their results, reasoning. On every run these payloads go to the engine, in order, as messages. Don't try to rebuild history from what's visible in the UI: without tool results and reasoning the model loses context on the next step, and some providers will refuse to continue at all.
The other chat_message fields (role, a text preview, call_id) are denormalization for the UI and queries.
Trigger: a new object in raw_chat_message.
1. Check the session state. If state = running, the user is typing while the agent is still answering. Do what a person does when interrupted: POST /runs/{id}/cancel. Cancellation is synchronous; the engine immediately returns the result with whatever text was already generated. Store it the same way as a callback result (step 5), send POST /runs/{id}/ack and move on. Never run two runs on one session at the same time: the second would get a history without the first one's answer.
2. Store the user message in chat_message. Use the raw_chat_message id as its id:
{ "role": "user",
"content": [ { "type": "text", "text": "{{text}}" } ],
"created_at": "{{date_created}}" }
Increment history_revision by 1. created_at is required: the engine uses it to show the model each message's date, the same one on every turn. That way history between turns is append-only and the provider's prompt cache keeps working.
3. Build the request body. A JS step is the easiest way (if you haven't worked with JSON in scenarios yet, start with the lesson on JSONs and links). It assembles system, the messages array (all session payloads sorted by created_at, seq) and the tools array from the reference records:
{
"contract_version": "1",
"idempotency_key": "{{raw_chat_message.id}}",
"history_revision": {{chat_session.history_revision}},
"llm": {
"provider": "{{assistant.provider}}",
"model": "{{assistant.model}}",
"api_key": {{assistant.api_key | null}},
"params": {{assistant.params}},
"pricing": {{assistant.pricing | null}}
},
"system": "{{system}}",
"messages": {{messages}},
"tools": {{tools}},
"limits": {{assistant.limits}},
"callback": { "url": "<callback endpoint URL, step 5>" },
"meta": {
"session_id": "{{chat_session.id}}",
"user_id": "{{user.id}}",
"tenant_id": "{{tenant.id}}",
"assistant_id": "{{assistant.id}}",
"timezone": "Europe/London"
}
}
A couple of practical details. Send empty fields as null, not an empty string: "api_key": "" fails validation. Build arrays in the JS step with a push loop, not concat. No need to add the date and time to the prompt: the engine appends them itself based on meta.timezone.
The system prompt can be assembled from data too. For example, a prompt core plus sections that depend on the conversation state: does the user have a filled-in profile, was the chat opened from a specific object's card. The same condition trims the tool set. The model has no say in this; plain scenario logic decides.
4. HTTP request: POST {ENGINE_URL}/runs with the header Authorization: Bearer {ENGINE_TOKEN}.
5. Handle the response:
202: the run has started. Create a run with id = run_id, status = running and deadline_at = now + timeout_ms + 120 s. Set the session to state = running and write stream_url and cancel_url into raw_chat_message. The frontend picks them up over the socket and subscribes to the stream;200: a repeat of the same idempotency_key — the run is already going, no second one was started. Do the same as above;409 result_pending: the previous run's result hasn't been stored yet. Fetch it via GET /runs/{id} (step 7), store it, send ack and retry the request;429 or 503: the engine is overloaded or restarting. Wait a couple of seconds and retry;400: an error in the body you built; the response says exactly what.The scenario ends here. No need to wait for the run to finish: the result will arrive at the callback.
A tool in theloop is a URL plus a JSON schema for its arguments. When the model asks for a tool, the engine sends a POST to that URL. In Directual it's a regular API endpoint with a synchronous scenario, and the URL and auth header live in the tool record.
The body the endpoint receives:
{ "contract_version": "1", "run_id": "run_…", "call_id": "call_…", "attempt": 1,
"origin_request_id": "…", "tool": "getOrders",
"args": { "orderId": "12345" },
"meta": { "session_id": "…", "user_id": "…", "tenant_id": "…" },
"result_url": "…" }
The scenario runs the query and replies. The response format is set in the endpoint's API response:
{ "status": "ok", "result": { … } }: the result goes to the model;{ "status": "error", "error": "order not found" }: the model sees the error and rephrases its request; the run doesn't fail.The main security rule: filter data by meta, not by args. The model can put anything into args, including someone else's user_id if it was talked into it in the prompt. The model never sees meta and can't forge it: Directual sets it when it starts the run. So the scenario looks up orders with user = meta.user_id and uses orderId from args only as a filter inside that selection.
Three more tips:
Authorization or X-*. For security reasons the engine passes only these to tools. A header with any other name (Api-Key, Token) is silently dropped, and the endpoint answers 401.tool_result_max_bytes, and you pay for every extra byte in tokens on every step of the loop.The interesting part is tools that change something: issue a refund, cancel an order, charge money. In theloop they have "effect": "write", and they follow a special rule:
A write endpoint changes nothing. It registers the intent — creates an operation — and always replies
{ "status": "deferred", "operation_id": "…" }.
In Directual it's the same API endpoint with a synchronous scenario, and the format is set in API response: { "status": "deferred", "operation_id": "{{operation.id}}" }.
On deferred, the engine ends the run with status awaiting_tool and forgets about it. Everything after that happens in Directual:
createReturn endpoint scenario creates an object in operation with status pending and replies deferred.chat_message (id = call_id) and starts a new run, this time without a new user message:{ "role": "tool", "call_id": "call_…", "tool": "createReturn",
"operation_id": "op_…", "result": { "return_id": "R-881" }, "is_error": false }
On decline, write "is_error": true, "error": "declined by user", and the model will explain to the user what happens next.
Why this way and not "let the tool issue the refund right away"? Three reasons:
deferred fits anything that takes hours: a manager's sign-off, a bank's response, document generation. The engine doesn't hold a connection, and Directual can wait as long as needed.To keep a repeated call from creating a second operation, its id must be deterministic: op_ + sha256 of meta.session_id, the tool name and origin_request_id (the user message id). sha256 is computed right in the scenario. A repeated call with the same data lands on the same object. Check the operation's version before executing it: the model may have changed the amount in a later call, and the user must not approve something they didn't see. When executing, pass operation.id to the external system (bank, payment provider) as its idempotency key. The full algorithm, including finding an existing operation, is in the spec, §6.4.
If the user types something new instead of clicking the card, close the pending call with a synthetic result "error": "superseded" and start a run with the new message. The operation itself stays, and its card can still be approved later.
When a run ends, the engine sends a POST to callback.url. The body comes in an envelope:
{ "payload": { "run_id": "run_…", "status": "completed", "messages": [ … ],
"usage": { … }, "tool_calls": [ … ], "meta": { … }, … } }
The single-field envelope is there specifically for Directual endpoints. An endpoint accepts only fields from its writable list. With a flat body you'd get … is not allowed for write on the first unknown field, and a messages array in a json field would fail with unsupported conversion from array to json. With the envelope the endpoint needs one writable field — payload (json) — and new result fields won't break it.
The endpoint's synchronous scenario:
X-Engine-Secret header to a constant and rejects the request on mismatch. Don't put the secret in the callback URL's query string: query strings end up in proxy logs.run by payload.run_id. The callback is delivered at-least-once; there will be duplicates. The endpoint itself doesn't set the record id, so the scenario handles deduplication: it looks up run by run_id and updates it instead of creating a new one. It writes result, status, usage fields and cost into run.payload.messages, upsert a chat_message with id = <run_id>:<seq> and payload as is. These are only the new messages from this run; the engine never returns old ones. The deterministic id makes redelivery safe: a duplicate overwrites the same objects.completed, stopped_limit, cancelled and failed move it to idle; awaiting_tool moves it to awaiting_tool with the list of pending call_ids. history_revision is incremented by 1.Before wiring up the engine, test the endpoint by hand: send a real result body with curl twice. After the second time there must be no duplicates in chat_message.
About stopped_limit: if the model hits max_steps, the engine gives it one last step without tools and still returns a text answer. It's not an error; show it as a normal reply. On failed, error.message already contains a user-friendly text in the ENGINE_LOCALE language. Show it as a system message; don't write it into history.
The frontend sees stream_url in its raw_chat_message over the socket and subscribes to the stream directly on the engine. No proxy through Directual is needed: the link already contains a token valid for this run only.
const es = new EventSource(msg.stream_url)
es.addEventListener('delta', e => appendText(JSON.parse(e.data).text))
es.addEventListener('tool_call', () => showStatus('Looking at the data…'))
es.addEventListener('done', () => es.close())
By default the stream carries only text and anonymized progress: no tool arguments, no tool results. Results may contain data the model needs to see but the user shouldn't see in full.
The "Stop" button is a POST cancel_url straight from the browser. Directual doesn't need to do anything special: the cancelled run's result arrives as a regular callback.
A subtle point: a done event in the browser doesn't mean history has been stored. Stored messages appear in chat_message after the callback; the stream is only a preview.
theloop keeps runs in memory. If the container crashes, the run is gone and no callback arrives. So you need a scheduled scenario, once a minute:
run with status = running and deadline_at < now;GET /runs/{id}. If a finished result comes back, store it as in step 5 and send POST /runs/{id}/ack. If the run is still going, extend the deadline;404 the run is lost: run.status = lost, session to idle, and a message to the user: "couldn't get an answer, please try again".lost isn't final. If a late callback does arrive, accept it: compare the history_revision in the result to the current one. If the revisions match, store it as usual. If the next turn has already happened, keep the result for audit but don't insert it into history.
| Directual | theloop |
|---|---|
| UI, users, permissions | the agent loop |
| conversation history and its order | streaming and cancellation |
| assistants, prompts, models and keys as data | provider retries, fallbacks, rate limiting |
| data and tools as API endpoints | tool calls, argument validation, loop protection |
| operations, approvals, executing actions | deferred instead of mutations |
client billing from usage |
exact token accounting per attempt |
Each side does its own job. Directual doesn't need to learn provider retries and SSE, and the engine doesn't need to know who your clients are and what they're allowed to do. Multi-tenancy comes for free: the engine holds no tenant state; everything arrives in the request.
What theloop isn't, at least for now: it's not an agent builder, not RAG and not a multi-agent system. One instance, runs in memory. Need knowledge-base search? Make it a read tool: an endpoint in Directual that searches and returns snippets.
"wait": true. The response then comes right in the request body — exactly the same body that will later arrive at the callback.The full contract is in SPEC.md. Appendix A is especially useful: a checklist of the orchestrator's obligations. Most production issues come from skipping one of those items. An example orchestrator on SQLite lives in examples/sqlite-harness: same logic, just tables instead of Directual structures.
Send questions, bugs and stories about the agent you built on Directual to GitHub issues.
If you're just getting started with agents, take our free AI agents course: in about an hour it gives you the foundation this setup builds on. More on what Directual offers for agent products is on the AI agents on Directual page.