npx lousho build --target=cloudflare-worker --agent=agent.yaml generates a module Worker (worker.ts), a wrangler.toml and a bundle, dist/worker.js, that the build checks for node: imports. It needs tsup and wrangler installed, and it takes an agent spec file or an agent directory (npx lousho build ./my-agent --target=cloudflare-worker). The Worker serves the same HTTP API as the other targets. This target has the tightest limits of the three, so they come first.
What works and what does not
lousho build rejects a spec whose provider or tool is not in the Worker column, and an agent directory with a folder or setting the Worker column leaves out, with a LOUSHO_DEPLOY_FAILED error that names it and suggests --target=node-server or --target=docker.
Providers and tools
The reasons behind the provider and tool rows:-
providers:
mock,openai,anthropicandopenrouter. The real providers are built on the VercelaiSDK’sgenerateText/streamTextplus@ai-sdk/openai/@ai-sdk/anthropic(openrouteris@ai-sdk/openaipointed athttps://openrouter.ai/api/v1), which are purefetch()/Web-standard implementations with nonode:*imports, so they bundle and run on Workers cleanly; the build’s leak check verifies every bundle.ollamais not supported here: it defaults to a localhttp://localhost:11434endpoint that a Worker can’t reach; usenode-serverordockerfor it; -
tools:
current-date,day-nameandhttp.web-fetchis not supported. On Node,httpandweb-fetchrefuse private destinations with a DNS lookup of their own (node:dnsinside anundiciAgent) that checks every address a host resolves to and connects to the address it checked, so DNS rebinding cannot get past the check. A Worker has neither: itsfetch()resolves names inside Cloudflare’s network and gives no hook to see or pin the address. What a Worker can check is the URL (scheme, host name, an IP-literal host); what it cannot guarantee is where a host name connects, so a name that resolves to an internal address would not be caught. So the Worker’shttp(the samehttp_requesttool name and input as on Node) is a different tool: it reaches only the host names you list. On the first URL and on every redirect it refuses:- any scheme but
http:andhttps:; - an IP-address host (
http://10.0.0.1/,http://[::1]/), even a listed one: there is no address check, so only names are allowed; - a host that does not match the
LOUSHO_HTTP_ALLOWbinding, a comma-separated list of host names (api.github.com) and*.wildcards (*.example.com, which matches subdomains only, notexample.com). Unset or empty, every request is refused (fail closed); an entry that is not a host name fails every request with an error naming it.
validateSSLoption) and the tool runs without a sandbox. A tool of your own that callsfetch()in a Worker gets no SSRF protection from the SDK. - any scheme but
Build and deploy
The build generates a module Worker (export default { fetch }) and bundles it as a
browser-platform ES module; the build fails if any node: import ends up in
dist/worker.js. wrangler.toml points main at dist/worker.js with
no_bundle = true, so exactly the verified bundle is uploaded. It builds and
runs with ai v4 (the default install) or ai v7 (with
@ai-sdk/openai/@ai-sdk/anthropic v4) installed, with no compatibility
flag (no nodejs_compat):
<TYPE>_API_KEY (for example
wrangler secret put OPENAI_API_KEY, wrangler secret put ANTHROPIC_API_KEY or
wrangler secret put OPENROUTER_API_KEY). The peer packages (@ai-sdk/openai for
openai and openrouter, @ai-sdk/anthropic for anthropic, and ai) must be
installed alongside @lousho/build-ai-agent for lousho build to bundle them.
Agent directories on a Worker. A Worker has no file system and cannot import
a file by path at run time, so the build reads the
agent directory on Node and writes agent.module.ts:
a static import of each tools/*.ts file (and of an agent.ts / agent.js
config), with instructions.md, a JSON/YAML config and the skills copied in as
JSON. The Worker builds the agent with the same rules as resolveAgentDir()
(config keys, tool exports, duplicate tool names):
- The config’s
modelmust name a provider of the table above ("model": "openai/gpt-4o-mini"), with its key in theOPENAI_API_KEYbinding; oragent.tssetsproviderto an instance. There is no environment to pick a default model from, so a directory without either is rejected. - A JSON/YAML config is checked by
lousho build; anagent.tsconfig is checked when the Worker starts, sowrangler deployreports the problem. subagents/,schedules/,channels/,memory/andprojectInstructionsare rejected, naming the folder or key. Usenode-serverordockerfor them.- Tool files are bundled from where they are, so their relative imports and their
node_modulesresolve as in development. A tool that imports a Node builtin fails the build’s leak check, which names the file. - In a tool or
agent.ts,@lousho/build-ai-agentis a Worker-safe subset of the package:defineTool,isDefinedTool,always,never,once,defineSkill,createMockProvider,MockLLMProvider,OpenAIProvider,AnthropicProvider,OpenRouterProvider,fromAiSdk,LLMProviderRegistry,textOf,SDKError,ConfigurationError,ToolExecutionErrorandValidationError. Importing another name fails the build with this list; type-only imports work for every type.
fetch() any host
(LOUSHO_HTTP_ALLOW restricts only the built-in http tool, not your code), and it
shares the isolate’s memory with every session the isolate serves (without
AGENT_CHECKPOINTS, that includes their sessions). The SDK does not hand a tool the
Worker’s env (the provider API keys, LOUSHO_API_TOKEN, the KV namespace), and the
build does not resolve cloudflare: imports, so import { env } from 'cloudflare:workers'
fails the build; but code in one isolate is not a security boundary. Deploy only tool
code you would trust with those bindings.
Bindings
The Worker serves the HTTP API:GET /health, POST /chat
streamed as SSE (ReadableStream), GET /chat/:sessionId, the approvals
endpoint and the deprecated { "message" } body. Its bindings:
wrangler.toml is scaffolded with the [[kv_namespaces]] block for
AGENT_CHECKPOINTS commented out, with the commands to create the namespace
(npx wrangler kv namespace create AGENT_CHECKPOINTS, plus a --preview
variant) and where to paste the resulting ids. Uncomment it and fill in the ids.
When the spec lists http, it also has a commented [vars] block for
LOUSHO_HTTP_ALLOW; uncomment it and list your hosts:
Sessions, checkpoints and approvals
KVStore(kvBinding, { prefix?, ttl?, historyLimit? }) is the AgentStore the
generated Worker builds from the binding. A hand-written Worker imports it from
the /kv subpath, which has no node:* import anywhere in its graph, with the
binding typed as KVBinding (the get/put/delete part of Cloudflare’s
KVNamespace, so @cloudflare/workers-types is not needed):
/kv also exports KVCheckpointStore (checkpoints only) and
CHECKPOINT_KV_BINDING ('AGENT_CHECKPOINTS', the binding name the generated
Worker reads). KVStore’s keys, with an optional prefix before each:
ttl: { sessions?, checkpoints?, approvals? } (seconds, KV accepts 60 or more)
makes each kind of record expire that long after its last write; by default
records are kept until deleted.
An approval that pauses a turn on one request can be decided by a later request
on another isolate: the checkpointed session names its pending approval, and the
approvals endpoint continues it from KV. That continuation’s events skip the
decided tool call’s tool.done; its run.done carries the final text.
The deprecated POST /chat { "message", "sessionId"? } keeps its earlier
behaviour on Workers: with a sessionId the run is checkpointed to
checkpoints/<sessionId> after each tool result and rehydrated by a later
request that reuses the sessionId (after a crash or a recycled isolate).
Scheduled runs
Cron triggers in the spec (triggers: [{ type: 'cron', cron: '0 9 * * MON', input: '...' }])
become [triggers] crons = [...] in wrangler.toml, and the generated Worker
exports a scheduled() handler that runs them as agent turns (session
schedule:<name>, see Schedules). Cloudflare
evaluates the expressions in UTC with a granularity of one minute; the
build rejects a timezone, a seconds field, an @daily shortcut or a numeric
day-of-week (LOUSHO_SCHEDULE_INVALID).
In a Worker you write yourself, wire an agent defined in code with
handleScheduled(agent, schedules, controller, ctx) (also exported from
@lousho/build-ai-agent/deploy-runtime-worker). It runs the schedules whose
cron equals controller.cron inside ctx.waitUntil() and never throws; list
the same expressions under [triggers] crons yourself:
Consistency
A Worker’s request lifetime is too short-lived for an in-memory or filesystem-backedCheckpointStore (see
Configuration / src/execution/checkpoint.ts for
what CheckpointStore is and why a run needs one to survive a crash or an
approval-gate pause). AGENT_CHECKPOINTS is that store, backed by KV
(KVCheckpointStore), and the one binding above is all it takes to opt in.
Why KV, not D1 or Durable Objects: a Checkpoint is one JSON blob keyed
by sessionId, read and written whole - exactly the shape Workers KV is
built for, with zero extra infrastructure beyond a namespace binding. D1
would buy relational query power this store never needs; a Durable Object
would buy strict per-session consistency at the cost of provisioning a DO
class/migration and paying for a stateful object per session. If your
workload genuinely needs strict read-after-write consistency across edge
locations (see the caveat below), a Durable-Object-backed CheckpointStore
is the natural upgrade path - implementing the same CheckpointStore
interface (save/load/delete) against a Durable Object namespace
instead of a KV namespace.
Eventual consistency - read this before relying on it for approval
workflows: Workers KV is an eventually consistent store. A put() is
immediately visible to the edge location that wrote it, but can take up to
~60 seconds to propagate to other Cloudflare edge locations globally. In
practice this means: if a session’s checkpoint is written on one edge
location and a follow-up request for the same sessionId lands on a
different edge location shortly after, that request could still observe
stale data (an older checkpoint, or a miss) rather than what was just
written. This matters most for approval-gated pauses, where the pause and
the human’s later approval-triggered resume are naturally two separate
requests that may hit different locations. This SDK does not - and, given
KV’s guarantees, cannot - promise strict read-after-write consistency here.
If your approval workflow can’t tolerate that window, route a given
session’s requests to a single Cloudflare location yourself (e.g. via
Durable Object-based request routing) or use a strongly-consistent store
instead of AGENT_CHECKPOINTS/KV. The same holds for sessions: two requests of
one session at the same moment can overwrite each other’s turn, since a KV
read-modify-write is not atomic.
Bundle size and Node builtins
The KV-backed stores (KVStore, KVCheckpointStore and CHECKPOINT_KV_BINDING,
exported from @lousho/build-ai-agent/kv) have no node:* references anywhere
in their dependency graph. The Worker runs the spec as a createAgent() agent, whose
Node-only imports (project instructions, the file session store, guardrail
patches, MCP over stdio) the build points at a shim that fails when used
(src/deploy/shims/node.worker.ts). The built dist/worker.js bundle is then
checked for node: and bare Node builtin specifiers as part of lousho build,
and fails the build if any are found; when one was imported (by an agent directory’s tool, say), the error names the importing file. One exception: ai v7 and
@ai-sdk/provider-utils v5 look up node:module, node:dns,
node:diagnostics_channel and node:async_hooks at run time with
process.getBuiltinModule(), only when they detect Node, and fall back to
fetch() (or skip telemetry tracing) elsewhere. Those four ids are accepted as
the argument of such a call and nowhere else. The bundle is larger than a single-turn
Worker (about 1.7 MB raw, 340 KB gzip for a mock agent on ai v4; about 3.4 MB
raw, 630 KB gzip on ai v7): lousho build reports
its size, and describe() compares it to Cloudflare’s script size limit.