DaloyJS does not run an LLM or an agent loop in core, on purpose. The loop is where your prompts, your model provider, and your memory live, and that belongs to you or to an agent framework. What DaloyJS is good at is everything around the loop that is easy to get wrong: streaming, auth, rate limits, validation, signed state, and audit trails.
We will host a small agent session API: the model calls tools, safe tools run straight away, risky tools pause for a human, and every step streams to the client. The complete, tested code is examples/agent-loop.ts in the DaloyJS repository. Its test suite runs this exact code, including the attacks.
A risky tool call with human approval
ClientDaloyJSYour model
01requestClientDaloyJSPOST /sessions/:id/messages"refund order A-1"
Nothing risky runs until the exact approval token comes back. A token for another session, another user, or an old call is refused, and a replay finds nothing pending.
1. The model and the tools
The model is a plain function, so you are not locked to a provider. Call your LLM SDK inside it and map the reply to a ModelTurn. Tools declare a Zod input schema, and risky: true marks the ones a human must approve (refunds, deletes, sending email, anything with side effects you would regret).
ts
// Your LLM call. Any provider fits: call its SDK here and map the reply.export type ModelTurn = | { type: "text"; text: string } | { type: "tool"; name: string; input: unknown };export type Model = (history: readonly HistoryItem[], signal: AbortSignal) => Promise<ModelTurn>;// A tool the model may call. Risky tools need a human approval first.export interface AgentTool<I = unknown> { description: string; input: z.ZodType<I>; risky?: boolean; run(input: I, ctx: { user: string; signal: AbortSignal }): unknown | Promise<unknown>;}
2. The loop
The loop is an async generator: each step it yields becomes a server-sent event. It stops on a text answer, on a pause for approval, when the request is aborted, or when the step budget runs out. A tool the model invented, or input that fails the schema, never runs: the error goes back into the history so the model can try again.
sseResponse turns the generator into a streaming response, and passing request.signal stops the loop when the client disconnects or the request times out, so you do not keep paying for model calls nobody is reading. Sessions are looked up by owner, so another user gets 404, not someone else's agent.
ts
app.post( "/sessions/:id/messages", { operationId: "sendSessionMessage", request: { params: z.object({ id: z.string().min(1) }).strict(), body: z.object({ text: z.string().min(1).max(8_000) }).strict(), }, acknowledgeNoResponseBodySchema: true, responses: { 200: { description: "Server-sent events for each loop step" } }, }, ({ params, body, state, request }) => { const session = sessionFor(userOf(state), params.id); // 404 if not yours if (session.pending) { return Response.json({ error: "Waiting for an approval on this session." }, { status: 409 }); } session.history.push({ role: "user", content: body.text }); return sseResponse(() => loop(session, request.signal), { signal: request.signal }); });
4. Pause for a human
When the model picks a risky tool, the loop stores the pending call and returns an approval token instead of running it. The token is a short-lived signed JWT (createJwtSigner) bound to this user, this session, and this exact tool call. The server keeps the pending input, so the client cannot swap in different arguments while approving.
ts
if (tool.risky) { session.pending = { toolCallId, name: turn.name, input: parsed.data }; const now = Math.floor(Date.now() / 1000); const approvalToken = await signer.sign({ sub: session.owner, // this user sid: session.id, // this session tcid: toolCallId, // this exact tool call iat: now, exp: now + ttl, // short-lived }); audit({ type: "approval_requested", user: session.owner, sessionId: session.id, tool: turn.name, toolCallId }); // Pause: nothing risky runs until a human sends this token back. yield { event: "approval_required", data: { toolCallId, tool: turn.name, input: parsed.data, approvalToken } }; return;}
Resuming verifies the signature and expiry, checks every binding, and clears the pending call before running it, so the same token cannot approve twice:
ts
let claims: Record<string, unknown>;try { claims = (await verifier.verify(body.approvalToken)).payload;} catch { return Response.json({ error: "Invalid or expired approval token." }, { status: 403 });}// Bound to this user, this session, and this exact tool call.if (claims.sub !== owner || claims.sid !== session.id || claims.tcid !== pending.toolCallId) { return Response.json({ error: "This approval token does not match." }, { status: 403 });}// Single use: clear it before running, so a replay finds nothing pending.session.pending = undefined;
5. Guardrails around the loop
Put real authentication in front (for example jwk()), and give each user a rate limit on how often they can drive the loop. The step budget (maxSteps) caps how many model calls one message can cost. Every tool call and approval decision goes to onAudit, which is where you connect your audit store.
ts
app.use(markAuthHook({ /* your real auth, e.g. jwk() */ }));// Per-user budget on how often a client may drive the loop.app.use( rateLimit({ windowMs: 60_000, max: 30, keyGenerator: (ctx) => `user:${String((ctx.state as Record<string, unknown>).user)}`, }));
Two more rules the example follows: a failing tool streams a generic "The tool failed." rather than its error text, and a new message on a session with a pending approval gets 409 until the human decides.
6. What the client sees
http
POST /sessions/3f0e.../messages{ "text": "refund order A-1" }event: tool_resultdata: {"toolCallId":"...","tool":"lookupOrder","output":{"orderId":"A-1","total":42}}event: approval_requireddata: {"toolCallId":"9b1c...","tool":"refundOrder","input":{"orderId":"A-1"},"approvalToken":"eyJ..."}POST /sessions/3f0e.../approvals{ "approvalToken": "eyJ...", "approve": true }event: tool_resultdata: {"toolCallId":"9b1c...","tool":"refundOrder","output":{"orderId":"A-1","refunded":true}}event: assistantdata: {"text":"Refunded order A-1."}event: donedata: {}
7. Test the attacks, not only the happy path
The example's tests prove each guard with a real attempt:
An approval token for another session is refused.
A tampered token and a token signed with another secret are refused.
Another user cannot see the session at all.
Replaying a used token finds nothing pending.
A model that calls tools forever hits the step budget.
Unknown tools and bad input never run, and tool errors never leak.
Next steps
Let the loop delegate to other agents with createA2aClient(), and forward trace context so one request stays one trace.
Move long-running tool work to background jobs and stream progress, instead of holding one request open.
Store sessions in your database: the in-memory map in the example is fine for one instance only.
Expose the same tools to AI clients with the MCP server.