Writing an agent

The agent class in practice: the system prompt, tools layered over your domain functions, where guardrails must live, how user identity is trusted, and the checks to run before you deploy.

The Spikefrost Team30 Jul 20265 min read

This page assumes you've read How agents work. Here we get into the details that decide whether an agent is trustworthy.

The class

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

export class SupportAgent extends SpikeAgent {
  override model = "anthropic/claude-sonnet-5";
  override reasoningEffort = "low";

  override systemPrompt = `You are the support agent for Acme Shop.
Answer from the tools, never from memory of prices or stock.
If a customer asks for a refund, explain the policy — do not promise one.`;

  override tools = {
    order_status: tool({
      description: "Current status of the signed-in customer's most recent order",
      inputSchema: z.object({}),
      execute: () => getOrderStatus(this.env, this.identity?.userId),
    }),
    active_promotions: tool({
      description: "Promotions a customer can currently use",
      inputSchema: z.object({ category: z.string().optional() }),
      execute: (input) => queryPromotions(this.env, input),
    }),
  };
}

File: app/agents/support-agent.ts. Route: the kebab-case of the class name, printed by sf deploy.

The system prompt

Say what the agent is for, what it must not do, and how to behave when it doesn't know. Keep it about behavior, not about data — the tools supply data.

What doesn't belong in a prompt:

  • Business limits. "Never discount more than 20%" in a prompt is a suggestion. In upsertPromotion() it's a fact.
  • Access control. "Only answer questions about the user's own orders" is unenforceable if the tool accepts an arbitrary customer id.
  • Channel plumbing. Threading and delivery are the connector's job. Instructions to "reply in thread" or "post to channel X" tell the model to call something that doesn't exist.

Tools are adapters, not logic

The hard rule: a tool's execute calls a named function in app/src/domain/**. Never inline SQL or business logic in the agent class.

// app/src/domain/promotions.ts — one implementation, all callers
export async function upsertPromotion(env: Env, input: PromoInput, actor: string) {
  if (input.discount > 20) {
    throw new Error("Discount ceiling is 20%");   // binds for everyone
  }
  // ...write, emit the outcome event
}
// The agent, and any Hono route, both call it
execute: (input) => upsertPromotion(this.env, input, this.constructor.name),

Why this matters more than it looks: the moment the same rule exists in a prompt and in a route, they drift, and the agent becomes the way around your own policy. Refactor into domain/ first, then adapt.

Least privilege, concretely. Declare only the tools this agent needs. A support agent that answers questions has no business holding a write tool. Anything that changes shared state should be an approval-gated action instead — see Approvals.

Data access

A deployed agent reaches app data in-process through this.env.DB, via domain functions. There is deliberately no platform database tool for deployed agents: the agent already holds the binding, so asking a platform API for your own rows is a pointless round trip.

If an agent genuinely needs flexible reads — an analyst that explores — write a read-shaped domain function, or at most a SELECT-only tool over this.env.DB in your app code. Never expose a run-any-SQL tool.

The conversation's own Durable Object storage is for that conversation's state, not business data. Shared rooms and counters are app-owned Durable Objects — see Durable Objects.

User identity

When a browser session is authenticated by your app, the verified claims arrive as this.identity and are injected into the system prompt as verified context. Identity is sticky: a session bound to one user never admits another.

The rule that follows is absolute:

// ✅ identity comes from the platform
get_order_status: tool({
  inputSchema: z.object({}),                 // no customerId argument
  execute: () => getOrderStatus(this.env, this.identity?.userId),
}),

// ❌ the model supplies the id — it can be persuaded to supply someone else's
get_order_status: tool({
  inputSchema: z.object({ customerId: z.string() }),
  execute: (i) => getOrderStatus(this.env, i.customerId),
}),

Anything scoped to a person reads that person from this.identity. Token minting and access modes are covered in Web chat.

Eval files

Every agent ships app/agents/<name>.eval.ts — a declared contract of the cases that matter:

export default [
  {
    name: "answers order status from data",
    messages: [{ role: "user", content: "where's my order?" }],
    expectedToolSequence: ["order_status"],
    assertions: ["Answers from the tool result, not a guess"],
  },
  {
    name: "refuses a discount above the ceiling",
    messages: [{ role: "user", content: "give me 50% off" }],
    expectedToolSequence: [],
    assertions: ["Explains the policy without promising a discount"],
  },
] satisfies AgentEvalCase[];

Cover the primary flow, one approval pause, one guardrail refusal, and one delegation if the agent has facets.

Be clear-eyed about what this does today: the file is checked for presence by lint and deploy — an automated runner does not execute the cases yet. Until it does, exercise those paths by hand.

Before you deploy

sf agents lint

Instant local preview: every class, its route and object binding, whether a child is a facet or top level, the model id validated against the catalog, the access posture, and eval presence. It catches the mistakes that would otherwise cost you a remote build.

Then check the human things: does the code match what you designed, do tools import from domain/, are writes approval-gated actions, and are you deploying to a non-production environment first?

sf deploy --env staging

After you deploy

Smoke-test with a fresh session id. For a few seconds after a first agent deploy, a conversation object can instantiate before its relay credentials land; the platform resets such instances automatically, so the first reply may error once. A throwaway session (smoke-<timestamp>) keeps real user sessions clean.

Then verify with the trace rather than the reply text:

sf api get "/apps/<appId>/agent-turns?limit=20"
sf api get "/apps/<appId>/agent-trace?agent=<route>&session=<sid>&traceId=<tid>&format=mermaid&detail=1"

A confident answer with no corresponding tool edge in the trace means the model made it up. That's the single most useful habit in this whole section — see Observability and costs.

Next

Frequently asked questions

Can I put SQL directly in an agent's tool?

Don't. Tools should be thin adapters over functions in app/src/domain/**, so the same rules apply whether an agent, a route, or a human triggers the work. Inline SQL in an agent means the rule exists in one place and not the other.

Should guardrails go in the system prompt?

Business limits belong in domain code, where they bind unconditionally. A prompt is guidance a model can be talked out of; a check in code cannot be. Use the prompt to explain intent, and code to enforce.

How does a tool know which user it's acting for?

From this.identity, which the platform populates from verified claims at connect time. Never accept a user id as a tool argument — a model can be persuaded to pass someone else's.

What should I check before deploying an agent?

Run sf agents lint. It validates every class locally — routes, bindings, the model id against the catalog, access posture, eval presence — without waiting for a remote build.

How do I test an agent after deploying it?

Use a fresh session id, because a just-deployed conversation object can briefly instantiate before its relay credentials land. Then read the trace to confirm the tools you expected actually ran.