lousho build turns an agent spec file (see Configuration)
into a deployable artifact for one target platform:
--out, default .lousho/build/<target>),
build (bundle with tsup) and describe (print the command to run or
deploy the result). tsup must be installed (npm install --save-dev tsup).
Every target answers
GET /health (200 ok) and serves the full
HTTP API below: sessions, SSE streaming, approvals and bearer auth.
Agent directories
node-server and docker also take an agent directory
in place of a spec file (the path may be positional or --agent):
server.ts calls resolveAgentDir() on dist/agent and
createDeployedServer(agent, { schedules, channels }), so the process starts the
directory’s schedules when it listens, mounts its
channels under /channels (they verify themselves; the chat
routes keep the bearer token), and logs schedules: ...; channels: ....
The directory’s code files are pre-bundled with the build’s tsup step into
dist/agent/**.js (one ESM build sharing one copy of the SDK, so a from '@lousho/build-ai-agent' import in a tool is the SDK the server runs), and the
rest of the directory is copied; nothing needs a TypeScript loader at run time.
dist/ is then ESM (dist/package.json says so). The Docker image copies it as
before. The optional dockerode sandbox is not bundled (it loads lazily), so a
directory that uses SubprocessSandbox must install it where it runs. The
Cloudflare Worker target still takes spec files only.
HTTP API
Every target serves the same/chat protocol as lousho dev
(CLI), from the same code (the Fetch-native
src/server/fetchRoutes.ts, which the Node server and the Worker both call), so
a page or script written against the dev server works against the deployed one.
Bodies over 1MB get
413, invalid JSON 400.
Auth
SetLOUSHO_API_TOKEN (on the Worker target, as a secret: npx wrangler secret put LOUSHO_API_TOKEN) and every route except /health requires
Authorization: Bearer <token>; anything else gets 401 with a JSON error
(the token is compared in constant time). Without it the server is open: that
is fine on 127.0.0.1, but treat the token as required for anything that is
not on localhost (the server logs a warning when it listens on another
interface without one, and the docker image listens on all interfaces).
Terminate TLS in front of the server, since a bearer token travels in clear
text over plain HTTP. Pass the token at run time (docker run -e LOUSHO_API_TOKEN=...).
Programmatically, adapter.scaffold(agentPath, outDir, { auth: { token } })
bakes a token into the built node-server or docker server for when the
variable is not set. The variable wins, and a baked token is readable in
dist/server.js, so prefer the variable. The Worker reads the token from its
LOUSHO_API_TOKEN binding only.
Sessions and the store
LOUSHO_STORE chooses where sessions, checkpoints and approvals live:
SQLite is one file on one disk, so run a single instance per database file.
node-server
dist/server.js is a single self-contained bundle (the SDK and its
dependencies are included), so it runs without npm install:
lousho dev, it binds to 127.0.0.1 unless you opt in to another
interface with --host=<h> (or HOST=<h>); the port comes from --port,
PORT, or defaults to 3000. SIGINT/SIGTERM close the agent before exiting.
Provider credentials are read from the same
environment variables as everywhere else (OPENAI_API_KEY, …).
docker
Reuses the node-server scaffold and bundle and adds a Dockerfile based
on node:22-slim that copies dist/ and runs node dist/server.js on port
3000. The image sets HOST=0.0.0.0 - inside a container the server has to
listen on all interfaces for docker run -p to reach it. Pass provider
credentials at run time:
cloudflare-worker
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):
- providers:
mock,openaiandanthropic. Theopenai/anthropicproviders are built on the VercelaiSDK’sgenerateText/streamTextplus@ai-sdk/openai/@ai-sdk/anthropic, which are purefetch()/Web-standard implementations with nonode:*imports anywhere in their dependency graph, so they bundle and run on Workers cleanly.ollamaandopenrouterare not supported here -ollamadefaults to a localhttp://localhost:11434endpoint that a Worker can’t reach, andopenrouterhasn’t had a Workers-compatibility audit; usenode-serverordockerfor those; - tools:
current-dateandday-name.httpis not supported: its SSRF protection resolves the hostname vianode:dnsand checks every resolved address against a denylist before connecting (closing a DNS-rebinding gap), then, forvalidateSSL: false, pins that TLS setting per request via a dedicatedundiciAgent. Workers’ nativefetch()has no equivalent hook to resolve a hostname up front and pin the connection to the verified IP, so a Workers version of this tool built on plainfetch()would silently drop that protection rather than just losing convenience functionality - it’s left unsupported rather than shipped weaker under the same name.
lousho build rejects a spec that uses anything else, with an error naming
the unsupported provider or tool. Provider API keys are read from Worker
bindings named <TYPE>_API_KEY (e.g. wrangler secret put OPENAI_API_KEY,
wrangler secret put ANTHROPIC_API_KEY) - the openai/anthropic
peer packages (@ai-sdk/openai/@ai-sdk/anthropic, ai) must be installed
alongside @lousho/build-ai-agent for lousho build to bundle them.
Bindings, sessions and the API on Workers
The Worker serves the HTTP API above:GET /health, POST /chat
streamed as SSE (ReadableStream), GET /chat/:sessionId, the approvals
endpoint and the deprecated { "message" } body. Its two 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.
KVStore(kvBinding, { prefix?, ttl? }) (src/deploy/kvStore.ts) is the
AgentStore the generated Worker builds from the binding. It is not exported
from any entry point of the package, so importing it in a hand-written Worker
is not supported yet. Its 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).
Optional peers in node and docker builds
dist/server.js bundles the SDK but leaves its optional peers (the peerDependenciesMeta
entries of its package.json: provider packages, dockerode, the MCP SDK, prompts, …) external, so a build
never needs one you do not use. Install, where the server runs, only the peers its agent needs (for example
@ai-sdk/openai for an OpenAI agent); a code path that needs one that is missing raises the SDK’s coded
missing-peer error. A spec’s cron triggers run on the node-server and docker targets as well as on Workers
(Schedules).
Cron triggers and handleScheduled
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:
Durable execution (pause/resume) on Workers
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.
The KV-backed stores (KVStore, KVCheckpointStore and CHECKPOINT_KV_BINDING,
in src/deploy/kvStore.ts, src/deploy/kvCheckpointStore.ts and
src/deploy/checkpointBinding.ts) 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. 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.
Custom targets
Targets areDeploymentAdapter objects (scaffold, build, describe)
registered by name with registerAdapter(); both are exported from the
package root.