Architecture
How the SDK, the runtime on Cloudflare and the dashboard fit together, and what happens on each message.
Components
┌──────────────┐ WebSocket / SSE / HTTPS ┌──────────────────────────────┐
│ Your app │ ───────────────────────────▶ │ api.kletso.ai (Worker) │
│ kletso_core │ ◀─────────────────────────── │ · sessions, keys, CORS │
│ kletso_flutter │ · realtime proxy │
└──────────────┘ │ · agent loop + tools │
│ · triggers + push (FCM) │
┌──────────────┐ HTTPS (cookie session) │ ┌────────────────────────┐ │
│ Dashboard │ ───────────────────────────▶ │ │ Durable Objects │ │
│ app.kletso.ai│ via kletso-admin Worker │ │ · ConversationDO │ │
└──────────────┘ │ │ · EndUserDO │ │
│ │ · TenantDO (limits) │ │
│ └────────────────────────┘ │
│ D1 (control plane) · KV │
└──────────────┬───────────────┘
│ HTTPS, as the user
┌────────▼────────┐
│ Your APIs (tools)│
│ Model provider │
└──────────────────┘
- Worker (
kletso-api). Stateless HTTP and WebSocket entry point. Validates keys and session tokens, enforces CORS for/v1/*, proxies realtime sockets to the conversation’s Durable Object, and hosts the control-plane API the dashboard uses. - ConversationDO. One per conversation. Holds the ordered event log and messages in SQLite, accepts hibernating WebSockets, replays events after a given sequence number on reconnect, runs agent turns, executes tool calls, evaluates confirmations and emits
app.notify. - EndUserDO. One per end user. Fans out user-level notifications to every open conversation socket of that user and tracks push tokens.
- TenantDO. Per-project rate limits (for example 30 user messages per minute per user).
- D1
kletso-control. Organisations, projects, environments, agents and versions, tools, components, triggers, secrets, API keys, members, end users and conversation summaries. - KV
CONFIG. Cached published configuration for fast reads on the hot path.
What happens on a message
- The SDK sends a
messageframe (orPOST /v1/conversations/:id/messages) with aclientIdfor idempotency. - The DO appends
message.createdfor the user, checks the per-user rate limit, then starts a turn and emitsagent.typing. - The agent loop builds the prompt from the published agent version: system prompt with
{{ context.* }}variables (public keys only), the tone, the allowed component catalogue, the last N messages, and the tool specs (your HTTP tools plus the built-insrender_ui,notify_app,app_command,handoff). - The provider streams tokens. Text deltas become
message.deltaevents. Tool calls becometool.started; HTTP tools run with the SSRF guard, templated URL and headers, and the user’s identity whenactAsUseris set. - Tools that require confirmation pause the turn and render a confirmation surface. The turn resumes when the user approves or declines.
render_uiresults are validated against the component allowlist and emitted asui.render. The loop continues until the model stops ormaxToolRoundsis reached.message.completedcloses the turn with usage, cost, latency and finish reason. Every event carries a sequence number; clients that reconnect send the last one they saw and receive the rest.
Environments and versions
A project has a development and a production environment, each with its own API keys. Agents are edited as a draft and published to an environment as an immutable version. The SDK’s publishable key selects the environment, so a build pointed at the development key sees development versions and nothing else changes in your app.
Where data lives
- Conversation events and messages: in the conversation’s Durable Object (SQLite), on Cloudflare’s network.
- Control-plane data: D1.
- Secrets (model keys, tool credentials, push credentials): D1, encrypted with a per-organisation data key that is itself wrapped by a Worker secret. See Security model.
- Nothing is sent to a model provider except what the agent needs for the turn: the prompt, recent messages, tool specs and tool results.