How agents work

Agents on Spikefrost are app code: TypeScript classes that deploy into your app's own worker, hold its bindings, and reach your data in-process. What that buys you, and the shape of an agent.

The Spikefrost Team30 Jul 20265 min read

An agent on Spikefrost is a class in your app's source code. It deploys into your app's own worker, holds the same bindings your routes hold, and each of its conversations is a Durable Object with durable state.

That's the whole architecture, and it's worth understanding because it explains most of the rules that follow.

Agents are app code

import { SpikeAgent } from "@spikefrost/agents";
import { tool } from "ai";
import { z } from "zod";
import { queryPromotions } from "../src/domain/promotions";

export class PromoManager extends SpikeAgent {
  override model = "anthropic/claude-sonnet-5";
  override systemPrompt = "You are the promotions manager. Margin floor 15%.";
  override tools = {
    query_promotions: tool({
      description: "List active promotions with their margins",
      inputSchema: z.object({}),
      execute: (i) => queryPromotions(this.env, i),
    }),
  };
}

The file goes in app/agents/promo-manager.ts. sf deploy compiles it into the worker. That's it — no registration step, no dashboard form, no separate deploy pipeline.

There is one way to create an agent, and this is it. Older apps may contain agents defined as database rows with prose "rulebooks", or as declared markdown under agent-config/agents/. Those paths still serve the apps that already use them, but the platform refuses them for new agents. If you find yourself writing one, you're on the wrong path.

What being app code buys you

Your data is a function call away. The agent holds this.env, so reading the app's database is the same in-process call your Hono routes make. There's no round trip to a platform API to fetch your own rows, and no separate copy of your business logic living in an agent's instructions.

Business rules stay in one place. An agent's tools are thin schema adapters over functions in app/src/domain/** — the same functions your routes call. A margin floor enforced in upsertPromotion() binds whether the caller is an agent, an HTTP request, or a human clicking a button.

It's versioned, reviewed, and promotable. An agent change is a commit. It goes through the same branch, the same build, and the same environment promotion as everything else, and you can roll it back the same way. Staging can run a different agent from production.

Deploy is registration. There's no registry to update and therefore no way for the registry to disagree with reality. To see what's actually deployed:

sf api get "/apps/<appId>/agent-describe"

That returns what each class really runs — model, tools with their approval flags, workspace grants, pinned repos, schedules, event types.

The shape of an agent

Declaration What it controls
model Which model runs its turns — a catalog id, verbatim (Models)
systemPrompt What it's for and how it behaves
tools Exactly what it may call (Tools and data)
getActions() Write operations that pause for human approval (Approvals)
access Who may open a browser chat session (Web chat)
getScheduledTasks() Recurring self-wakes (Schedules)
reasoningEffort How hard it thinks
workspaceTools, pinnedRepo Optional file/repo access, opt-in only

Anything not declared, the agent does not have. There is no inherited filesystem, no shell, no ambient database tool. Least privilege is the default, not a setting — a read-only analyst agent that never declares a write tool cannot be argued into performing a write.

Conversations are Durable Objects

Each conversation is an instance of the agent's class, keyed by session. Practical consequences:

  • State is durable. A conversation survives restarts and can span minutes of tool work.
  • Sessions are isolated. Two users talking to the same agent share nothing.
  • Identity is sticky. A session bound to one authenticated user never admits another.
  • The class name is storage identity. Renaming a class orphans its conversations, so the deploy blocks and makes you choose deliberately. Treat a rename as a migration, not a refactor.

Turns, jobs, and traces

A turn is one pass of the agent: it reads the conversation, thinks, calls tools, and produces a reply. Every turn is traced automatically — each tool call, delegation, and model step becomes an edge with duration and token counts. Nothing to instrument.

Turns cost money, and that's metered against your team's balance. All inference goes through the platform relay, which is what makes spend visible and budget caps enforceable. You never hold a provider API key and never call a model vendor directly.

The workflow

  1. Design first. Write down what the agent is for, which model, which tools, and where judgment is genuinely needed versus where code should decide. Most agents are one class; split only for parallelism, context isolation, or a narrower grant.
  2. Write the class, with tools layered over domain functions.
  3. Ship an eval fileapp/agents/<name>.eval.ts, declaring the cases that matter: the primary flow, an approval pause, a guardrail refusal.
  4. Lint, then deploy.
sf agents lint     # every class, its route, model validated against the catalog
sf deploy --env staging

sf agents lint is a local check with no remote build round trip — it catches a mistyped model id, a missing eval file, or a facet placed at top level before you wait on a deploy.

Be honest with yourself about the eval file: today it's a declared contract checked for presence, not an automated runner. Exercise the critical paths by hand until that ships.

One note on dependencies

The @spikefrost/agents package is vendored by the platform — the build injects it. Never add it to package.json. Your app's own code imports light subpaths (@spikefrost/agents/access, /events) rather than the full index; importing the index from app code pulls the agent SDK through the browser-facing build and fails at runtime.

When you add the first agent to an app that predates agents, the deploy self-heals the build's dependency set and tells you what it added.

Next

Frequently asked questions

Where do agents live?

In your app's source, as classes under app/agents/**. They compile into the app's own worker on deploy, so they run beside your routes with the same bindings.

Do I configure an agent in a dashboard?

No. An agent is code, and deploying it is what registers it. There is no separate registry to keep in sync — which is the point.

How does an agent read my app's data?

In-process, through your own domain functions on the app's database binding. There is no platform database tool for a deployed agent, because it already holds the binding.

Can an agent do anything I didn't allow?

No. An agent gets exactly the tools it declares. There is no inherited filesystem, shell, or database access — a read-only agent cannot be talked into a write.

Is each conversation isolated?

Yes. Every conversation is its own Durable Object instance with its own state, so two users talking to the same agent never share context.