Filesystem-first agent layer for Litro apps: tools that return server-rendered web components, durable NDJSON session streams, pluggable model providers. Deploys anywhere Nitro deploys.
npm install @beatzball/litro-agent pnpm add @beatzball/litro-agent
Filesystem-first AI agents for Litro, with tools that can return server-rendered components.
agents/<name>/agent.ts becomes a durable endpoint; files in tools/ become its toolsdata for the model and server-rendered html for the page. The model never sees the htmlSessionEvents over a single endpointRequires @beatzball/litro. Agents are wired into the Litro server by a Nitro plugin, so this package is not usable standalone.
npm install @beatzball/litro-agent
# or
pnpm add @beatzball/litro-agent
Wiring an app takes a few edits to nitro.config.ts, package.json, and .gitignore. The Agents guide walks through them; create-litro template wiring is not in yet.
agents/
_config.ts <- optional runtime config (sessions, telemetry)
demo/
agent.ts <- default export: defineAgent({ ... })
instructions.md <- system prompt
tools/
get-weather.ts <- filename is the tool name
Each agent becomes one endpoint:
POST /__litro/agent/<agent>/<session> run a turn, stream the events
GET /__litro/agent/<agent>/<session> replay from ?from=<seq>, then live-tail
// agents/demo/agent.ts
import { defineAgent } from '@beatzball/litro-agent';
import { anthropic } from '@beatzball/litro-agent/providers/anthropic';
export default defineAgent({
model: anthropic({ model: 'claude-sonnet-5' }),
instructions: './instructions.md',
});
Tools declare an input schema using any Standard Schema validator. The schema is required — it is what the model is handed and what the input is validated against before execute runs.
// agents/demo/tools/get-weather.ts
import { defineTool } from '@beatzball/litro-agent';
export default defineTool({
description: 'Look up the current weather for a city',
input: citySchema,
async execute({ city }) {
return { city, tempC: 21, summary: 'sunny' };
},
});
A tool can return a rendered component instead of plain data. ui() server-renders it and returns both halves:
import { defineTool } from '@beatzball/litro-agent';
import { ui } from '@beatzball/litro-agent/ui';
import { html } from 'lit';
import { WeatherCard } from '../../../components/weather-card.js';
void WeatherCard; // named import + void: bare side-effect imports get tree-shaken
export default defineTool({
description: 'Show the weather as a card',
input: citySchema,
async execute({ city }) {
const tempC = 21;
const summary = 'sunny';
return ui(html`<weather-card .city=${city} .tempC=${tempC}></weather-card>`, {
data: { city, tempC, summary },
});
},
});
The model observes data; the html streams to the page and is never shown to the model. That separation is enforced in the runtime — for direct returns, generator returns, and nested results alike — and it holds in traces too.
The renderer follows LITRO_ADAPTER: Lit (Declarative Shadow DOM via @lit-labs/ssr) or FAST. The component must be registered server-side. Elena is not supported yet.
litro mcp serve puts one agent's tools, and the ui:// documents
litro mcp-app build packed, in front of an MCP host over stdio:
// the host's server configuration
{
"command": "<project>/node_modules/.bin/litro",
"args": ["mcp", "serve", "--project", "<project>"]
}
Both halves of that are load-bearing, and neither is obvious. A host starts
this command with a working directory of its own choosing, and at least one
desktop host ignores a cwd field in its configuration entirely:
npx litro resolves the name from the
CURRENT directory, so from anywhere else it goes to the public registry and
fails with npm error code ENOVERSIONS / No versions available for litro,
which says nothing about the real cause.--project. Otherwise the project is the host's working directory,
which is not yours.Verified against Claude Desktop, which rendered the ui:// card on screen.
--agent <name> picks the agent when there is more than one; --apps-dir <dir>
points at a build output other than dist/mcp-apps; --timeout <ms> and
--max-result-bytes <n> tune the per-call guards.
Several servers run side by side — the command opens no port, and it exits when its host disconnects.
Tool names are global to a host. Two servers exposing a tool of the same name are ambiguous: the host picks one and the reader cannot tell which answered. Name a tool for what it does in the world, not just within its project.
| To get | Change needed |
|---|---|
| tools over stdio | nothing. litro mcp serve |
| a tool's app linked to it | one field on that tool: app: '<manifest name>' |
| the apps served | nothing, if litro mcp-app build has run |
| the SDK | pnpm add -D @modelcontextprotocol/sdk |
export default defineTool({
description: 'Get the current weather for a city.',
input: citySchema,
app: 'weather-card', // a dist/mcp-apps/manifest.json entry, or a literal ui:// address
async execute({ city }) { /* ... */ },
});
tools/list publishes _meta.ui.resourceUri for that tool, and the host reads
the document with resources/read. An app name the manifest does not list is a
startup failure, because the MCP Apps specification requires the resource to
exist on the server. visibility is emitted only when you set it —
app: { name: 'weather-card', visibility: ['app'] } hides a tool from the model
— because the extension already defaults to ['model', 'app'].
inputSchema is only as good as your schema library. It comes from
~standard.jsonSchema.input, so zod and arktype convert and a hand-rolled
schema publishes { type: 'object' }, which tells a host nothing about the
arguments.
ctx.event is undefined over stdio. There is no request, so a tool that
reads a cookie or a header has nothing to read.
@modelcontextprotocol/sdk is an optional peer dependency. Only a project
that serves MCP installs it; every other project builds and runs without it, and
litro mcp serve says what to add when it is missing.
Streamable HTTP, auth, progress notifications and outputSchema are later
phases of design/specs/2026-09-24-mcp-server.md and are not here yet.
| Import | Use |
|---|---|
@beatzball/litro-agent/providers/anthropic |
Anthropic models |
@beatzball/litro-agent/providers/openai-compatible |
OpenAI, or any compatible endpoint — Ollama, LM Studio, vLLM |
@beatzball/litro-agent/providers/scripted |
Deterministic canned responses for tests and demos |
API keys are read from the environment only. openaiCompatible takes an optional system name so a local endpoint is not mislabelled as OpenAI in telemetry.
A session is an append-only log. The default store writes JSONL under .litro/sessions/; .litro/ holds conversation data and must be gitignored. Point the default store somewhere else with fileSessionStore({ dir }), or with the LITRO_AGENT_SESSIONS_DIR environment variable when you cannot reach the config — two servers sharing one project directory each need their own.
sqliteSessionStore (from @beatzball/litro-agent/sessions/sqlite) is the alternative for deployments running more than one instance against shared storage. It adds crash-safe sequence numbers and a cross-instance turn lease. It requires Node 22.5+, which is why it sits behind its own subpath — Node 20 users are unaffected and keep the JSONL store.
Agent turns can emit OpenTelemetry spans following the GenAI semantic conventions: invoke_agent per turn, chat per provider round, execute_tool per tool call.
Off unless you supply a tracer, and your app brings OpenTelemetry rather than this package depending on it:
// agents/_config.ts
import * as otel from '@opentelemetry/api';
import { defineAgentConfig } from '@beatzball/litro-agent';
import { otelTracer } from '@beatzball/litro-agent/telemetry';
export default defineAgentConfig({
telemetry: { tracer: otelTracer(otel) },
});
Prompt and completion content is not recorded by default. Even with capture enabled, a UI tool's rendered html is never recorded — only its data.
@beatzball/litro-agent/client is browser-safe — it imports the isomorphic wire protocol and type-only shapes, never H3 or a Node built-in.
import { agentSession, hydrateUIResult } from '@beatzball/litro-agent/client';
Full guide, security model, and the deferred-feature list: litro.dev/docs/agents
Apache-2.0
Changelog
@modelcontextprotocol/sdk optional peer dependency range from
^1.30.0 to ^1.31.0, so an app that opts into the MCP server starts from a
release patched against GHSA-6qxp-vccf-f47h (High, CVSS 7.5), where an OAuth
client could send credentials to an authorization server chosen by the MCP
server.49c281a: Serve an agent's tools and ui:// apps over MCP Streamable HTTP, as a Nitro route.
The same server phase 1 put behind stdio now also answers over HTTP at
/__litro/mcp/:agent. One URL per agent, and one new subpath,
@beatzball/litro-agent/mcp-handler, that the agents plugin's generated
server/stubs/mcp-handler.ts feeds the build-time manifest into. A project turns
it on with two static handler entries in nitro.config.ts — POST for the
JSON-RPC channel, OPTIONS for the CORS preflight.
The gates, in order: Origin must be allowlisted when it is present (absent is
allowed, because a host that is not a browser sends none), then a static
Authorization: Bearer token compared in constant time, then the agent's own
access(event) guard. No wildcard CORS is ever sent. A build that is neither
marked local nor given a token refuses to start, and says which environment
variable to set; litro dev needs neither.
LITRO_MCP_TOKEN, LITRO_MCP_LOCAL, LITRO_MCP_ORIGINS and
LITRO_MCP_APPS_DIR configure it. The route is stateless — no session id is
issued or validated — so it works wherever Nitro deploys to more than one
instance, and a client that disconnects mid-call closes its transport instead of
leaking one.
bfe941d: Add an MCP server behind a new ./mcp-server subpath: one agent's tools, and
the ui:// documents litro mcp-app build packed, in front of an MCP host over
stdio.
tools/list from the agent's tools, with inputSchema from the tool's
Standard Schema and _meta.ui.resourceUri for a tool that names an app.tools/call validates through the tool's schema, executes, drains a generator
to its final value, and answers with text in content and data in
structuredContent. A UIResult's html is never sent; the ui:// document
is the view.resources/list and resources/read from manifest.json, bytes untouched. A
uri from a request is looked up in that manifest or refused, and never becomes
a file path.structuredContent), a result over 1 MiB is truncated with a marker
and the real size logged, a call has a 30s timeout, and a tool that throws
answers isError: true rather than a protocol error. An unknown tool and
invalid arguments are protocol errors, as the specification groups them.app field on defineTool, naming a manifest.json entry or a
literal ui:// address, with optional visibility. An app the manifest does
not list is a startup failure. The chat loop ignores the field.@modelcontextprotocol/sdk is an optional peer dependency. A project that
does not serve MCP is unaffected; one that does gets an actionable message
naming the package when it is missing.The pure half of a tool call — look up, validate, execute, drain, split a
UIResult — moves to one function that the chat loop and the server both call.
Every store append, its order, and every span stay in the loop.
serveMcpStdio reports when its host disconnects, so the caller can shut down
rather than leaving a process behind.
Streamable HTTP, auth, progress notifications and outputSchema are later
phases.
defineMcpApp accepts domain and writes it to _meta.ui.domain in the resource descriptor. It asks the host for a dedicated sandbox origin — for an OAuth callback, a CORS policy, or an API key allowlist. The MCP Apps spec leaves the format to each host, so the value is carried through unchanged; a value that is not a non-empty string fails at defineMcpApp with a clear message.~standard.jsonSchema.input), the loop sends that schema, so the model sees field names, types and required fields. A vendor without a converter, a converter that throws, or a result that is not an object schema still sends { type: 'object' }, as before.window.litroMcp.callTool() and readResource() now reject after 30 seconds when the host does not answer, instead of staying pending forever. The error is a TimeoutError whose message names the method, and a late answer is ignored. Pass { timeoutMs } as the last argument to change it per call; 0 waits forever. callTool(name, args) works as before. The ui/initialize handshake has no timeout.bf8b54e: runtime and apply are parsed at build time, and a replaced fill step says so
Both are browser source as a STRING, so nothing type-checks them, nothing
lints them, and a typo is invisible until the document is inside a host —
where a SyntaxError kills the whole script tag and the view never fills, with
no error anyone sees and a build that reported success.
buildMcpAppDocument now compiles both with node:vm and refuses source that
does not parse. It is a PARSE, NOT AN EXECUTION: no author code runs at build
time. apply is wrapped in parentheses first, since it is inlined as the
right-hand side of an assignment and has to be an expression.
It cannot tell you the code is correct, and it cannot tell you the code is
safe. runtime and apply are trusted author code by design.
Separately, a runtime that assigns litroMcpApply now prints a warning. That
assignment REPLACES the bridge's default fill step — the one that refuses
innerHTML, srcdoc and on* in a tool result — and it does so without the
app ever declaring an apply. A custom apply makes the same choice but
declares itself by existing; this route did not. A warning rather than an
error, because replacing the fill step is a legitimate thing to want.
The check is a text match, and it says so: it finds the name in assignment
position however it is reached — window., globalThis., self., a local
alias, bracket notation, ??= — and strips comments first. A string that
mentions the name still warns, and Object.assign(window, { litroMcpApply })
does not. Both gaps are documented rather than implied away.
5c09b18: MCP Apps: uri is now optional in defineMcpApp()
buildMcpAppDocument(app, { uri }) takes a fallback address, which is what
lets litro mcp-app build derive one — package name as the authority, file
path as the path. An app that declares
its own uri keeps it — the config wins over the fallback — so no existing app
needs an edit.
Code that reads the config may. MCP_APP_CONFIG is exported, and
app[MCP_APP_CONFIG].uri is now typed string | undefined — so assigning it
to a string no longer type-checks — and it is undefined at runtime for an
app whose address is derived. Read descriptor.uri from the result of
buildMcpAppDocument() instead, or uri from the built .json descriptor or
manifest.json; those always hold the resolved address.
URI validation moved from defineMcpApp() to buildMcpAppDocument(). An
absent uri is only an error once it is known that no fallback is coming, and
defineMcpApp() cannot know that. A malformed one is still rejected, and now
a path-derived address is held to the same standard as a hand-written one.
Calling buildMcpAppDocument() standalone is unchanged: with no file there is
nothing to derive from, so it throws unless the config carries a uri or one
is passed in.
df9971f: Add @beatzball/litro-agent/mcp-app — packages a Litro component as an MCP Apps ui:// resource.
defineMcpApp() declares the app; buildMcpAppDocument() server-renders the shell and returns one self-contained HTML5 document plus the resources/* descriptor (text/html;profile=mcp-app, nested _meta.ui).
A ui:// resource is a static, cached, data-free template — hosts prefetch it and reuse it across tool calls — so the shell is rendered with no data, and the inlined bridge fills it from structuredContent when ui/notifications/tool-result arrives after the ui/initialize handshake. The bridge also answers ping and ui/resource-teardown, reports its size so a flexible-height host can fit it, and exposes window.litroMcp.callTool().
The default fill step refuses scripting sinks (on*, innerHTML, outerHTML, srcdoc, src, href, style, …) and reports what it refused. structuredContent is server JSON and the host's default CSP permits inline event handlers, so a plain Object.assign onto the element would let a tool result execute code holding window.litroMcp.callTool.
The descriptor carries name as well as uri — the base MCP Resource type requires both, and a server forwarding the descriptor into resources/list would otherwise emit an invalid entry.
Packing fails if the document would load anything from outside itself, because the host's default CSP is default-src 'none' and such a fetch fails silently inside the iframe.
Works with the Lit and FAST renderers — the packager routes through ui(), so it follows LITRO_ADAPTER. Elena is not supported, because ui() does not support it yet.
a9345f4: fileSessionStore no longer breaks permanently when its session directory disappears, and can be pointed elsewhere with LITRO_AGENT_SESSIONS_DIR.
The store creates .litro/sessions once and caches that result for the life of the process. If the directory was then removed — a cleanup script, a log rotation, a tmpfs reaper — every later append failed with ENOENT forever and the agent endpoint returned a 500 error event on every turn until the server restarted. It now recreates the directory and retries the append once.
LITRO_AGENT_SESSIONS_DIR overrides the default directory (an explicit fileSessionStore({ dir }) still wins). It exists for the case where two servers run against the same project directory and must not share session state.
Updated dependencies [df9971f]
source export condition so consumers always resolve the compiled
output. Publishing source pointed installed apps at TypeScript that Vite does
not transpile inside node_modules, which produced an unparseable client
bundle on Vite 8. No API change.040f4b6: ## OpenTelemetry GenAI spans
Add OpenTelemetry GenAI spans (v0.1 milestone, item 1).
Agent turns now emit spans following the OpenTelemetry GenAI semantic
conventions: invoke_agent <agent> per turn, chat <model> per provider
round, and execute_tool <tool> per tool call. Tool spans are parented to
the turn rather than to the chat round that dispatched them, so a tool run
reads as a sibling of the model call, not a child of it.
Telemetry is opt-in and off by default. Configure it in agents/_config.ts:
import * as otel from "@opentelemetry/api";
import { defineAgentConfig } from "@beatzball/litro-agent";
import { otelTracer } from "@beatzball/litro-agent/telemetry";
export default defineAgentConfig({
telemetry: { tracer: otelTracer(otel) },
});
The package takes no dependency on @opentelemetry/api — the namespace is
passed in, so the runtime always uses the same api singleton the app
registered its SDK against. Any object matching the exported AgentTracer
interface works just as well. With no tracer configured every span hook
short-circuits to a shared no-op before building any attributes.
Prompt, completion and tool payloads are NOT recorded unless
captureContent: true is set, matching the semantic conventions' opt-in
stance on content capture. A UIResult's html is never recorded even
then — the rule that keeps rendered markup out of the model channel now
also keeps it out of traces. Span errors carry a semconv error.type and
a message, never a stack.
Also additive, in support of the above:
Provider gains an optional info: { system, model } used for
gen_ai.provider.name / gen_ai.request.model; the built-in
openai-compatible, anthropic and scripted providers populate it, and the
attributes are omitted entirely for a provider that does not.openaiCompatible() accepts a system option so an adapter pointed at a
non-OpenAI compatible endpoint (Ollama, vLLM, ...) is not mislabelled in
traces.done provider event's usage field, which
was previously accepted by the type and discarded. Token counts appear
per round on chat spans and summed on the invoke_agent span.No behaviour changes: the session log, the NDJSON wire stream, and the event sequence are byte-identical whether telemetry is on or off.
node:sqlite session storeAdd a node:sqlite session store with a cross-instance turn lease (v0.1
milestone, item 2).
@beatzball/litro-agent/sessions/sqlite is an alternative to the default
JSONL store, for apps running more than one instance:
// agents/_config.ts
import { defineAgentConfig } from "@beatzball/litro-agent";
import { sqliteSessionStore } from "@beatzball/litro-agent/sessions/sqlite";
export default defineAgentConfig({
sessions: sqliteSessionStore({ path: ".litro/sessions.db" }),
});
What it adds over the JSONL store:
seq is computed as MAX(seq)+1 inside
the same BEGIN IMMEDIATE transaction as the insert, so there is no
in-memory counter to lose on restart and two writers can never mint the
same seq. WAL mode lets replays and live tails read while a turn appends;
synchronous = FULL keeps the durability promise that makes
append-before-wire meaningful.SessionStore gains two OPTIONAL methods,
acquireLease and isLeased. A store that implements them upgrades the
runtime's per-process turn lock to a cross-instance one — without it, two
app instances each pass their own local check and run concurrent turns on
one session. The holder renews on a heartbeat; losing the lease stops the
heartbeat but never aborts an in-flight turn, per the durability
contract. A lease only lapses if an instance stalls or dies for a full
TTL, at which point another instance may recover the session.GET that reconnects while the turn is
running on a DIFFERENT instance cannot reach that instance's in-process
broadcast, so it polls the store instead — checking the lease before each
drain, so a turn that ends between two polls is never truncated. Without
this, multi-instance would be a half-claim: the lock would be honest but
reconnects would silently return a partial log.Requires Node 22.5+ (node:sqlite), which is why it lives behind its
own subpath and is never in the default import graph — the package's
engines range still admits Node 20, where the default JSONL store
continues to work unchanged. Node prints its own ExperimentalWarning for
node:sqlite.
Known limitations, deliberate for this milestone: DatabaseSync is
synchronous, so an append briefly blocks the event loop (correctness first,
matching the JSONL store's fsync-per-append); and the default
fileSessionStore implements no lease, so it stays single-instance exactly
as before — nothing about existing deployments changes.
nitropack from ^2.13.1 to ^2.13.4, resolving Medium-severity advisories GHSA-5w89-w975-hf9q and GHSA-9phm-9p8f-hw5m.fileSessionStore no longer pays a mkdir syscall on every single append() call (cached once per store instance, with retry-on-failure preserved), and its internal per-session promise-chain registry now drops an entry once it settles and nothing has chained onto it since, instead of retaining one for every distinct session id for the lifetime of the process.agents/<name>/ directories become durable session endpoints (POST|GET /__litro/agent/:agent/:session) streaming NDJSON session events. Tools (defineTool, Standard Schema input) can return UIResults — server-rendered design-system components (Lit DSD or FAST) whose data is what the model observes while the HTML streams to the surface. Includes openai-compatible, anthropic, and scripted providers; JSONL session store with reconnect/replay (resume(fromSeq)); browser client with hydrateUIResult; Nitro build plugin following the Server Actions wiring pattern.