Flue Agent Platform
The AI assistant — the chat panel described in AI Assistant and the backend the MCP server's ask_zudo_agent tool talks to — is served by its own Cloudflare Worker, agent-server (zudo-agent-server), built on Flue 2.0.3 rather than hand-rolled routing like sync-server and publish-server. This page is the architecture reference for that Worker: why it is a separate service, how a conversation is addressed and owned, why credentials never ride the model's own message channel, how tool calls reach the workspace, the edit-safety contract, quota, retention, and how it deploys.
Why a separate Worker
sync-server owns the workspace's REST surface and Better Auth. agent-server owns the one thing neither of the other two Workers needs: a durable, multi-turn LLM conversation with tool-calling. Splitting it out keeps sync-server's auth and file-storage code free of LLM concerns, and lets agent-server deploy independently, on its own build pipeline (see Deploy topology below), without touching the Worker every sync client depends on. This is the same one-worker-one-concern shape publish-server already follows — see Architecture Overview and the philosophy page for the general framing.
agent-server binds to sync-server twice, for two separately revocable reasons: AUTH_SERVER reaches Better Auth's JWKS/introspection endpoint (a public-hostname fetch cannot reach it in production), and SYNC_API is the workspace/file REST surface — specifically the Automation API — that the assistant's own tools call on the user's behalf. Both are Cloudflare service bindings, not HTTP calls to a public URL.
The model
Both AI surfaces this Worker serves — the multi-turn assistant and the one-shot inline AI command — run the same Cloudflare Workers AI model: @cf/, a tool-calling-capable chat model. There is no per-user model selector; the id is pinned in one module (workers/) so the two callers — Flue's provider-qualified cloudflare/<id> form for the assistant, and the raw binding id for the inline route — can never drift apart.
Flue's conversation model, and what it does not provide
Flue gives this Worker a Durable-Object-backed conversation primitive: 'use agent' functions become generated Durable Object classes, useModel/useTool/useInitialData wire up the model and its tools, and createAgentRouter() exposes HTTP routes (POST to send, GET for history/SSE, POST /:id/abort, attachment reads) in front of one such class. ZudoAssistant (workers/) is that function; its Durable Object class name and binding (FlueZudoAssistantAgent / FLUE_ZUDO_ASSISTANT_AGENT) are generated from the function's name, not its file name — renaming the function is a storage migration, renaming the file is not.
What Flue 2.0.3 does not provide, verified against the shipped router contract:
No auth. A mounted
createAgentRouter()answers any request that reaches it, with no check of who is asking.No conversation ownership. A conversation id is a caller-chosen path segment. Anyone who can guess or observe one id can read that conversation's full history.
No enumeration. There is no "list my conversations" route.
No delete. The router serves no
DELETE(a probe against a mounted router gets405,Allow: GET, HEAD, POST), and the underlyingConversationStreamStoreis documented as append-only — canonical records are never updated or rewritten.
Every one of those gaps is a real security or product requirement for a note-taking app's assistant, so agent-server supplies all four itself, in front of Flue rather than inside it.
The ingress: everything Flue doesn't own
workers/ is the answer. The Flue agent router is deliberately never mounted — app.ts builds it (createAgentRouter(ZudoAssistant)) but only ever hands it to the ingress as an injected forward function; no app.route() call ever exposes it directly. Every request under / — send, read history, SSE, abort, delete — passes through the ingress first, which rewrites the request before (if at all) forwarding it downstream. The reasoning, verbatim from the code comment: "a / middleware in front of a mount can be bypassed by a routing mistake; a router with no mount cannot be reached by any path this file did not build."
Three things the ingress owns that Flue does not:
1. Conversation identity — server-minted addresses
The client names a conversation with its own short id (1–64 chars of letters, digits, -, _ — the shape the renderer's uuid/nanoid already produces). That id is a name, never the router's actual address. The real, Flue-facing conversation id is minted server-side from the verified principal:
user:{userId}:workspace:{workspaceId}:conv:{clientConversationId}:{random}and recorded in a D1 index table (agent_conversations, workers/), keyed by (userId, workspaceId, clientConversationId). Because the id that actually reaches Flue is not the id the client supplied, guessing another user's client-side id is harmless — the lookup that resolves it to a real address is scoped by the caller's own verified userId, and a miss mints a new conversation under the caller's own identity rather than joining someone else's stream. The random tail on the minted id is load-bearing for deletion (see Retention and deletion below), not just uniqueness.
The client never learns the minted address: a send response's streamUrl is rewritten (rewriteAdmission()) to hand back the client-facing URL instead of the internal one Flue's 202 names.
2. Credentials — never inside the model's own message
This is the load-bearing lesson of the whole design, and it reverses an earlier plan. The epic that specified this Worker (#4621, sub #4632) originally called for per-delivery Flue signal attributes carrying {userId, workspaceId, keySessionId, bearer}. Reading @flue/ disqualifies that channel for anything secret, on two independent grounds:
It becomes prompt text.
renderSignalMessage()serializes a signal as<tag type="…" attrName="attrValue" …>— every attribute, verbatim — and that string is fed straight into the model's context bybuildConversationContextEntries(). An "attribute" on a signal is not metadata to Flue; it is literal text the model reads.It becomes permanent history. A delivery is a canonical, append-only record.
GET /:id?view=historyreplayssignal.attributesforever, and Flue has no delete for it (see above).
So the workspace credential a turn needs — the caller's bearer token and their X-Key-Session id — travels out of band, in a short-TTL D1 row (agent_delivery_credentials, workers/), keyed by the conversation's minted Flue id and written before the send is admitted to Flue. Agent tools, running inside the generated Durable Object (which shares the Worker's bindings but not its request scope or isolate — so a module-scoped in-memory map would not reach them), read the credential via cloudflare:workers bindings at call time, never at render time. A message sent with no key session actively clears the row rather than leaving the previous turn's credential behind — access has to be re-granted per turn the user actually wants it for, not inherited from an earlier one.
The credential row's TTL is 15 minutes (DELIVERY_CREDENTIAL_TTL_MS), deliberately shorter than the workspace key session's own 1-hour TTL (see Workspace Access Gating on the Automation API page): it exists to let this turn read the workspace, not to stand in as a second, longer-lived grant. A turn recovered long after the fact (Flue retries submissions for up to an hour by default) finds the slot expired and the tools report exactly that — see Tool failures read as sentences below.
initialData — { userId, workspaceId }, validated once against a valibot schema on the conversation's first message — is a different thing from the delivery credential and is fine to keep in Flue's own state: it names whose conversation this is and which workspace it reads, not a bearer token, and the ingress supplies it from the already-verified principal rather than trusting a client claim.
3. Deletion and listing — entirely app-owned
GET / on the ingress lists the caller's own conversations from the D1 index, most-recently-used first — without this, an id the client forgot is unreachable and un-retirable forever, and there would be nothing to show a user deciding what to delete.
DELETE /:conversationId best-effort aborts any in-flight turn, then drops both the delivery-credential row and the index row. See Retention and deletion for exactly what that does and does not erase.
Authentication and introspection
Requests reach the ingress only after requireAgentInvoke() (workers/), Hono middleware registered twice per mount — once for the bare path and once for its wildcard, because Hono's / does not match the bare /, and for the agent mount the bare path is the conversation-listing route. It guards / as well: that route shipped with a self-contained RS256 verifier of its own because this middleware did not exist yet, and the two were reconciled once it did. One consequence is intended — a PAT holding agent:invoke can call /, since both surfaces spend the same quota and a caller allowed to run a conversation has no reason to be refused a one-shot transform. / is the only unauthenticated route.
The middleware hashes the presented bearer token for a local cache key, then — on a cache miss — calls GET / on sync-server over the AUTH_SERVER service binding (workers/). PAT verification (hashing, expiry/revocation SQL, scope parsing) lives only in sync-server; this Worker never re-implements it, by the epic's own decision. Introspection reports { userId, scopes, workspaceId, tokenKind }; the middleware accepts the caller when scopes includes full or agent:invoke (an interactive Better Auth/dev-login session always introspects with scopes: ["full"], so a single check covers both cases). See Automation API → Authentication for the PAT scope table and the introspection response shape in full.
Tools: how a turn reaches the workspace
ZudoAssistant mounts six tools, each a thin front for the Automation API, dispatched over the SYNC_API service binding:
| Tool | Kind | What it does |
|---|---|---|
list_documents | read-only | Lists note paths under an optional folder prefix. |
read_document | read-only | Reads one note's exact content, by a path the model has actually seen. |
search_documents | read-only | Regex search — files / content / count modes, same as the Automation API's own search route. |
query_kanban | read-only | Answers "what's coming up?" from kanban card frontmatter dates (timing > due > absolute notify), the way the app itself understands them — never by reading card bodies and guessing. Takes an optional IANA timezone, defaulting to Asia/Tokyo: "today" is a calendar date, and a UTC anchor would be a day behind for the first nine hours of every JST day. Every answer restates the date and zone it was anchored to. Its bounded discovery/recovery walk can consume up to 85 of the shared Automation API requests. |
create_note | write | Creates a brand-new note; picks a collision-free filename automatically, following the tray's own naming convention (numbered {N}.md, or dated YYYYMMDD-{slug}.md for the one message-style tray, archives). |
propose_edit | write, preview-only | See Edit safety below. |
Three invariants hold across every tool (stated once, in document-tools.ts, and honored by every sibling module):
A tool is not an authorization boundary. The model chooses only which document to look at; whose documents it may reach is decided before the model runs (the delivery credential) and re-checked by
sync-serveron every single call — scope gate, workspace binding, subscription, key session, ownership. A tool call never grants access; it only spends access the user already granted.Credentials are re-read per call, never captured at render time — a submission can be recovered long after the render that queued it, so a closure-captured credential would be stale.
Tool failures read as sentences, not error codes. An expired key session comes back as "Workspace access expired — re-open it from the consent dialog in the app, then ask me again", not
KEY_SESSION_EXPIRED. The model relays tool output to a human, so the typed automation error codes (FORBIDDEN_SCOPE,RATE_LIMITED,VALIDATION_FAILED, …) are branched on inside the tool and mapped to a plain-English sentence the system prompt instructs the model to relay verbatim rather than reword or retry.
Tool failures read as sentences, not error codes
All six tools go through ONE mapper, in document-tools.ts's syncApiCall: KEY_SESSION_EXPIRED → a re-open-workspace-access sentence, FORBIDDEN_SCOPE → a grant-access sentence (worded for read vs. write), RATE_LIMITED → a slow-down sentence, typed SUBSCRIPTION_REQUIRED at 402 → a subscription sentence (the status fallback also tolerates an older plain-402 server), and so on. write-tools.ts used to carry a second copy on the theory that a security-relevant behavior should not depend on one module importing another correctly; in practice two copies is how a security-relevant behavior drifts, and a missing import is a compile error while a diverged duplicate is not. The system prompt makes relaying these sentences verbatim an explicit instruction, specifically so a small model does not "helpfully" reword VALIDATION_FAILED's already-safe explanation into something vaguer, or retry a call that will fail the same way every time.
The expiry sentence carries one deliberate exception to "codes never leak": it LEADS with the literal token [KEY_SESSION_EXPIRED]. A headless caller cannot see the delivery credential expire — the only evidence it gets is a failed tool call inside someone else's turn — so packages/zudotext-mcp's AgentClient detects a dead key session by scanning errorText for exactly that substring, reopens the session, and resends the message once. The human-readable action still follows in the same sentence.
Edit safety: existing-note edits never self-commit
This is the epic's pinned contract (#4621), enforced structurally rather than by prompt instruction alone. The distinction is create versus replace:
create_notewrites directly. The model is choosing to author a brand-new note, so there is nothing of the user's to clobber — the tool issues a realPUT(create-only; it failsVERSION_CONFLICTif something already exists at the chosen path) and returns the path and version.propose_editissues no write at all. It reads the target note's current content and version, builds a unified diff against the model's proposed new content, and returns a preview object —{ previewId, path, baseVersion, summary, unifiedDiff, newContent, expiresAt }— that the client renders as an Apply/Reject card. The system prompt requires the model to describe this as "a proposal I've prepared for you to review," never as an edit that happened.Applying is a client-side, user-authenticated write. The Apply action calls
agent.applyEdit()with the user's own auth andexpectedVersion: baseVersion— so a note that changed underneath the proposal (someone edited it, or a previous proposal in the same conversation already applied) failsVERSION_CONFLICTrather than silently overwriting. The UI (AI Assistant → Conversation thread) offers Apply, Reject, and Re-propose (which asks the model to re-read the note and produce a fresh diff against current content) for exactly this case.Previews expire. A preview is valid for 15 minutes (
PREVIEW_TTL_MS) — the same short-lived-grant shape as the delivery credential, not a standing offer to apply later.
Quota
Every AI surface spends from one shared cap: 100 AI turns per user per UTC day (AI_DAILY_QUOTA, workers/), tracked in the ai_usage_log table in the sync-db D1 database. Today that means this Worker's two surfaces — the assistant's multi-turn conversations and the one-shot / route — both draw from it; before its retirement (#4639), sync-server's own / route spent from the exact same table. A "turn" is one user message plus its full response. Both surfaces spend it at admission, in one atomic INSERT … ON CONFLICT DO UPDATE … WHERE request_count < ? RETURNING upsert that runs before the message is forwarded or the model is called. The increment IS the gate: a read-then-check followed by a later increment leaves a window in which every request in a burst reads the same under-limit count and all of them proceed, so exactly one of a burst at limit−1 is admitted only because the refusal happens inside the same statement that raises the count. The cost is that a turn which then fails still burns a unit — accepted pre-release, because over-charging by one request on a rare failure is a smaller problem than a burst spending an unbounded number of them for free.
The quota module is intentionally duplicated between sync-server and agent-server rather than imported from one into the other: the two Workers are independently deployed with no shared source boundary today, and the duplication is a deliberate choice to enforce the same cap against the same table, not accidental drift.
Retention and deletion — honest about what "delete" does
DELETE / removes the D1 index row and the delivery-credential row. That makes the conversation unlisted and unreachable: the derived Flue id was the only address the router would ever answer to, and dropping the index row means nothing outside that table remembers it. It does not erase the underlying Durable Object's append-only stream — Flue 2.0.3 has no delete anywhere in its contract (no router DELETE, no store delete, no deleteAll), and reaching into a Flue-generated Durable Object to wipe its own SQLite storage would mean writing code inside a class this codebase does not author. The honest description, and the one every user-facing surface uses: the conversation becomes orphaned and unaddressable immediately; its bytes age out with the Durable Object rather than being erased at the moment of deletion.
This is why the client-side toast on Clear/delete shows the server's own sentence ("The conversation is unlisted and no longer reachable; a new conversation with the same id starts fresh. Stored transcript records age out with the underlying Durable Object rather than being erased now.") instead of a locally-authored "Deleted" message — only the server is in a position to make an accurate claim about what happened.
Two more disclosures worth stating plainly, since they follow directly from the design above:
Conversation history retains plaintext excerpts of workspace notes. A tool result — the content of a note the model read, or the diff it proposed — becomes part of the durable conversation the same way any other turn does. There is no separate at-rest encryption for conversation content the way there is for workspace files.
The workspace-access consent dialog says this up front. Before the first key session opens for an AI conversation,
KeySessionConsentDialog(tauri-) states: "Keys are held in server memory for up to 1 hour, then discarded automatically. Conversation history may retain plaintext excerpts of your notes. You can revoke access at any time by ending the session." Declining is a fully supported outcome — the chat keeps working with no workspace access; only tool calls that need it fail with the sentences described in Tool failures read as sentences above.app/ renderer/ components/ key- session- consent- dialog. tsx
See Automation API → Key Sessions for the wire-level mechanics of the key session itself, and its :::caution block for the zero-knowledge tradeoff this consent screen exists to disclose.
Deploy topology
agent-server is the one Worker in this repo not built by a hand-written wrangler.toml + src/ pair (see workers/ for the full build contract). Instead:
Vite builds it, not wrangler directly.
wrangler.jsoncdeliberately declares nomain— theflue()Vite plugin generates the Worker entry from the'use agent'modules, andcloudflare({ config: flueWorkerConfig() })merges that entry plus the per-agent Durable Object bindings intodist/.<worker>/ wrangler. json pnpm build(vite build) must run beforepnpm deploy(wrangler deploy) —vite buildalso writes., the redirect that makes a barewrangler/ deploy/ config. json wrangler deploypick up the generated config.flue({ providers: ["cloudflare"] })keeps the bundle small. Omitting theprovidersarray bundles every built-in pi-ai provider factory (Anthropic, OpenAI, Google, Mistral, Azure, …) for roughly 1.25 MB gzipped, almost none of which this Worker can reach — it only ever dispatches through the Workers AI binding. Naming justcloudflarepulls in that one binding provider and cuts the upload to roughly 780 KB gzipped.Durable Object migrations are generated and append-only.
ZudoAssistantgenerates classFlueZudoAssistantAgent, declared undermigrations: [{ tag: "v1", new_sqlite_classes: [...] }]inwrangler.jsonc. Renaming the exported function is a storage migration (a new migration tag); renaming the source file is not. A shipped tag is never edited — always verify the generated class name from the builtwrangler.jsonrather than predicting it.compatibility_datehas a hard floor of 2026-04-01 — Flue requires SQLite-backed Durable Objects,nodejs_compatv2, andAsyncLocalStorage, and fails the build below that date.
See also
Automation API — the wire contract every agent tool call and MCP note-tool call rides on: scoped PATs, key sessions, the read/write routes, and the typed error envelope.
MCP integration — connecting Claude Code, Codex, or another MCP client to a workspace through
@takazudo/zudotext-mcp, including its ownask_zudo_agenttool that talks to this same conversation ingress.AI Assistant — the in-app chat panel this Worker serves: thread tabs, the workspace-access banner, tool-call and edit-proposal cards.
AI Provider — the model, sign-in requirement, and quota from the end-user's point of view.
Sync Architecture → End-to-End Guarantees — how the key-session and conversation-retention exceptions fit into the workspace's overall zero-knowledge design.