docsbuild agentswriting services

Writing services

A service is host-side code bound to one agent. It does what no extension can — and every guardrail is yours to write.

A service lives under blueprint/service/ and ships with the blueprint. The server spawns it as a child process when the agent starts, supervises it across crashes, and shuts it down with the agent. It is the agent's private back-end — same trust as installing a package on your machine, because that's effectively what it is.

Reach traded for responsibility

A service runs outside the agent's container, on the host. Full network. Full host filesystem. Full credentials from config/ext/service/.env. The sandbox the agent runs inside does not apply. That is what makes services useful for the work no extension covers — an internal API behind a VPN, multi-account orchestration, an OAuth callback listener, a CLI that lives on your machine, anything that wants credentials the agent should never see.

It is also what makes the trade real. Token discipline, approval gates, prompt-injection guards, capability gating — none of what the framework gives extensions for free comes with a service. The author writes those guardrails by hand if the work needs them, or accepts their absence.

🔒 SECURITY
Every line of service code runs with operator trust. A blueprint with a service is, for the operator who installs it, a package they're choosing to run on their host. Author services with that on the table — and when you ship a blueprint that contains one, name in the README what the service does and what credentials it expects.

When to reach for one

A service is not the first reach. Before writing one, check the two cheaper options.

  • A bundled extension does the job. Email, calendar, web-fetch, whatsapp ship with Cast and are engineered for safety and token discipline. If one fits, enable it in capabilities.json and move on. The full list is on Extensions.
  • A reputable third-party MCP server does the job. Cast can plug an external MCP server straight into the agent — no wrapper code. The trust posture is different (third-party code, no framework surfaces) but for read-only tools from a vendor you trust, it's the shorter path. See Capabilities for the trade-offs.

A service is the right reach when the work has state outside the conversation — a long-running connection, a scheduled sync, a webhook listener, a queryable index that persists across sessions — or when the work needs host credentials the container should never see. If the work is a single stateless tool call, an extension or an MCP server is almost always the better shape.

⚠ HEADS UP
Don't write a service to escape an extension's discipline. If the email extension's approval gate is in your way, the fix is in its config — not a custom email service that ships the gate off the boat. Services exist to extend reach, not to slip safety.

Three handles into the agent

A service has three ways to affect what the agent does. Most useful services use two of three; few need all three. Knowing which handle a given concern wants is the design decision that shapes everything else about the service.

MCP tools — synchronous, agent-pulled

The service hosts an MCP server on a Unix socket at mcp/agent.sock inside the agent folder. The agent runner discovers the socket on startup and the tools appear in the agent's tool list — indistinguishable, from the agent's side, from any other tool. The agent calls; the service answers; the result lands in the next turn. Use this handle when the agent needs to ask.

src/index.ts (excerpt)
svc.tool(
  'crm__search',
  'Search the CRM by company name or domain.',
  { query: z.string(), limit: z.number().default(10) },
  async ({ query, limit }) => {
    const rows = await db.search(query, limit);
    return { content: [{ type: 'text', text: format(rows) }] };
  },
);

Pushes — asynchronous, service-initiated

The service can drop a message into one of the agent's channels at any time — when a webhook fires, when a poll surfaces new data, when a long job finishes. The push opens a conversation on the named channel (or wakes an existing one) and the agent runs a turn with the message as input. Use this handle when something outside the agent's awareness happens and the agent should react.

src/index.ts (excerpt)
const fresh = await pollCRM();
if (fresh.length > 0) {
  await svc.routeMessage(
    'inbox',
    `${fresh.length} new CRM leads — see /shared/service/leads.json`,
  );
}

Prompt context — passive, every turn

Writing shared/ext/service/agent-context.md contributes a block to the agent's system prompt, wrapped in <service-context>, on every turn the agent runs. Use this handle to teach the agent what's available right now — which sources are synced, which tools are live, what state the service is in — without making the agent ask first.

src/index.ts (excerpt)
svc.prompt.set('crm', `## CRM
- ${count} leads synced (last poll ${ts})
- query with crm__search; data lives in /shared/service/leads.json`);
svc.prompt.commit();
💡 TIP
This handle is load-bearing — and re-pays every turn forever. The discipline Designing well asks of prompt.md applies here too: write the one paragraph the agent needs to know right now, not a dump of everything the service could say. A growing agent-context.md is one of the most expensive mistakes a service author makes.

The contract

Small and explicit. A manifest, an entrypoint, three runtime directories with different write semantics, and one env var the server hands the process. The SDK, @getcast/agent-service-base, wraps the IPC handshake, the MCP socket, the prompt-context writer, and credential loading. A useful service is around thirty lines.

blueprint/service/manifest.json

json · identity + entrypoint
{
  "name": "crm",
  "version": "0.1.0",
  "entry": "src/index.ts"
}

All fields optional. name and version are informational. entry resolves relative to blueprint/service/; a .ts or .tsx entry runs with tsx, anything else with node. With no entry, the server looks for index.js. With no manifest, no service.

blueprint/service/

source · portable, ships with the blueprint

Your service source. During development, set "entry": "src/index.ts" and the server runs the TypeScript directly via tsx on every startup. For distribution, bundle to index.js (esbuild, node20) so operators don't need a TypeScript toolchain to install your blueprint.

ext/service/

runtime · private state, service CWD

Server-created, the process's working directory. Your scratch space — SQLite databases, caches, OAuth tokens, anything the service alone needs to remember. Not mounted into the agent's container.

shared/ext/service/

runtime · agent-visible output

Mounted read-only into the agent's container at /shared/service. Anything the agent should be able to read — a JSON dump for a tool to grep, an attachment to reference, the dynamic agent-context.md — goes here. The service writes; the agent reads.

config/ext/service/.env

config · operator-owned secrets

Operator territory, not part of the blueprint, not committed. The SDK loads it for you as svc.secrets. When you ship a blueprint with a service, document which keys the operator needs to fill — the install can't be completed without them.

The raw IPC message catalog and the full CAST_SERVICE_CONFIG env shape live in packages/agent-schema/src/v1/SPEC.md §9. Reach for them only when writing a service without the SDK.

Lifecycle, and what you owe

The framework supervises the process so the author doesn't have to. Start with the agent. Restart on crash with jittered exponential backoff (one second to thirty). Trip a breaker if five crashes land inside five minutes — the service moves to failed and waits for an operator to restart it from the console, instead of looping forever. Graceful shutdown on a shutdown message, five seconds, then SIGKILL. Daemonization, restart loops, crash budgets — not your code to write.

In return, the discipline the framework can't enforce:

  • No hot-reload. The entrypoint resolution is cached at server start. Edits to service source or manifest take effect on server restart, not service restart. The console's restart button reruns the existing entrypoint — it does not re-read the manifest.
  • Log errors to stderr. What the agent sees when an MCP tool throws is whatever the protocol surfaces, often summarized once by the LLM before it reaches the operator. The server log is the only window into what really failed. console.error the exception or it's invisible.
  • Write only to the dirs you own. ext/service/ and shared/ext/service/. Writing into memory/, state/, or config/ from a service can corrupt agent or server state — and the framework will not stop you.
  • Keep agent-context.md terse. Re-paid every turn, forever. Same posture as identity files. A paragraph that changes shape per state beats a static dump that grows.
  • Send the ready handshake. The SDK sends it for you. Without it, startup hangs until timeout — so if you bypass the SDK, send { type: 'ready' } over IPC the moment the service is actually serving.
  • Capabilities — the framing this page deepens: extension, service, or external MCP, and how to pick.
  • Authoring blueprints — the rest of the surfaces a blueprint exposes; the service is one of them.
  • Designing well — context discipline above the parts; the prompt-context handle in particular lives or dies by it.
  • Creating an extension — when the capability is reusable across agents and ought to ship as one.