Write Custom Backend
Contract
By the end of this skill the consumer has:
- A server route that exports a
POSThandler returned bycreatePilotHandler. - A clear
modelargument in one of the three supported shapes (string,LanguageModelinstance, thunk). - A system prompt (server-owned; always prepended before client-derived instructions).
- A
maxStepschosen deliberately, not accepted blindly. - (Optionally)
getProviderOptionsfor per-request provider tuning (cache hints, thinking budgets).
Iron Law: server-owned system prompt always wins
The handler composes the final system prompt as
[options.system, body.system, body.context] (see composeSystemPrompt
in packages/agentickit/src/server/handler.ts lines 613-628).
options.system is server-controlled and comes first; client-derived
sections (.pilot/ skills, registered state) are appended. If you
need guardrails the client cannot tamper with (tone, safety instructions,
tenant isolation), put them in options.system. A compromised client can
inject body.system but cannot shadow options.system.
Phases
Phase 1: pick a runtime shape
createPilotHandler returns (request: Request) => Promise<Response>,
so it works anywhere the Web Fetch API is available:
- Next.js App Router (tested):
export const POST = createPilotHandler({...}). - Bun: pass into
Bun.serve({ fetch: POST }). - Cloudflare Workers: export as
export default { fetch: POST }. - Hono:
app.post("/api/pilot", (c) => POST(c.req.raw)).
Phase 2: write the minimal route
// app/api/pilot/route.ts
import { createPilotHandler } from "@hec-ovi/agentickit/server";
export const POST = createPilotHandler({});
Auto-detects a provider from env. See skills/choose-provider/SKILL.md
for the priority order.
Phase 3: the full options surface
import { createPilotHandler } from "@hec-ovi/agentickit/server";
export const POST = createPilotHandler({
model: "anthropic/claude-sonnet-4-5",
system: [
"You are the support copilot for a kanban app.",
"Always confirm destructive actions before invoking them.",
"Never expose internal card IDs; reference cards by title.",
].join(" "),
maxSteps: 5,
getProviderOptions: () => ({
anthropic: { cacheControl: { type: "ephemeral" } },
}),
});
The verified options shape (from CreatePilotHandlerOptions in
server/handler.ts lines 224-275):
interface CreatePilotHandlerOptions {
system?: string;
model?: ModelSpec; // see Phase 4
getProviderOptions?: () => Record<string, unknown>;
maxSteps?: number; // default 5
}
Phase 4: the three ModelSpec shapes
From the exported type (line 115):
type ModelSpec = string | LanguageModel | (() => LanguageModel | Promise<LanguageModel>);
Shape 1: string.
createPilotHandler({ model: "openai/gpt-4o" });
createPilotHandler({ model: "openrouter/qwen/qwen3-coder:free" });
createPilotHandler({ model: "auto" }); // synonym for omitting model
Resolution order (see planResolution in lines 532-585):
- Direct provider key (
OPENAI_API_KEY,ANTHROPIC_API_KEY, etc.) + matching@ai-sdk/*peer package installed → direct adapter. - No direct key but
AI_GATEWAY_API_KEY/VERCEL_OIDC_TOKENset → string handed tostreamTextverbatim; the Vercel AI Gateway resolves it. - Neither → the handler factory throws at creation time with a clear error naming the missing env var and package.
Supported prefixes (line 29): openai, anthropic, groq, openrouter,
google, mistral. Anything else throws "unsupported model prefix" at
handler creation.
Shape 2: LanguageModel instance.
import { createOllama } from "ai-sdk-ollama";
const ollama = createOllama();
export const POST = createPilotHandler({ model: ollama("llama3.3") });
Detected via isLanguageModelInstance (lines 380-392). Prefix validation
is skipped. Use this for Ollama, Azure, Bedrock, or any custom adapter.
Shape 3: thunk.
createPilotHandler({
model: async () => {
const token = await refreshToken();
return customAdapter(token, "model-id");
},
});
Called exactly once at handler creation (lines 710-722). The resolved
value must be a LanguageModel instance; strings are rejected with a
clear error. Useful for async auth exchanges at startup.
Phase 5: per-request model overrides
The client can pass <Pilot model="openai/gpt-4o-mini"> and the string
is forwarded in the request body. The server re-validates the prefix
against the same allow-list (lines 841-853), so the client cannot inject
an arbitrary string.
Overrides are only honored when options.model is a string (lines
854-860); if the handler uses a LanguageModel instance or thunk, the
client override returns a 400 unsupported_provider. This is deliberate:
the instance / thunk paths don't have a resolver plumbed.
Phase 6: maxSteps
The handler passes stopWhen: stepCountIs(options.maxSteps ?? 5) to
streamText (line 907). A "step" is one model call plus any tool calls
it emits. Five is enough for call → result → follow-up → polish;
raise it to 10+ if your app has chained tools that legitimately need
more round-trips. Lower it to cap cost.
Phase 7: getProviderOptions
Called for every request (line 888-891). Returns provider-specific
options forwarded verbatim to streamText({ providerOptions }). Use for:
- Anthropic cache control.
- OpenAI reasoning / thinking budgets.
- Groq tool-call retry settings.
- Per-provider temperature overrides.
Do NOT return API keys here; they live in env vars. The option's type is
intentionally loose (Record<string, unknown>) to avoid leaking AI SDK
internal types through the public API.
Phase 8: the error envelope
All non-streaming errors return a narrow JSON envelope
(PilotErrorBody, lines 283-287):
{ "error": "human-readable message", "code": "invalid_request" }
Codes (narrow, client-matchable):
invalid_request: 400, body didn't parse.unsupported_provider: 400, model prefix not allowed.internal_error: 500, catchall.method_not_allowed: 405, not a POST.
Stack traces never leak (line 925: console.error server-side, sanitized
message to client).
Phase 9: CORS
The handler emits permissive CORS headers by default (line 367-372).
Consumers who need tighter policy wrap the handler in their own
middleware. Don't modify the response headers from inside
getProviderOptions (they're applied after streamText returns).
Anti-Patterns
- Putting the API key in
getProviderOptions. Env vars, always. - Returning
new Response()from insidegetProviderOptions. That hook feedsstreamText, not the response pipeline. - Parsing the request body yourself to inject state. The handler's Zod
schema (lines 325-359) validates exactly what
useChatsends; add a custom preprocessor by wrapping the handler in middleware, not by patching internals. - Setting
maxSteps: 100. At that point a runaway loop costs real money. If you need more steps, examine whether the chain is the right tool. A multi-step LLM loop is rarely the cheapest or most reliable path. - Assuming
streamTextis called on the server for every tool. Client tools (declared viausePilotAction) stream back to the browser. The server wraps them withdynamicTooland a throwingexecutethat signals "this is client-side only" (lines 668-672).
Output Format
After configuring, report:
- The runtime (Next.js / Bun / Workers / Hono).
- The
modelshape (string / instance / thunk) and the specific value. - Whether
system/maxSteps/getProviderOptionsare set and why. - A one-sentence description of the error-handling contract the consumer should expect.
Tools Used
- Edit the server route file.
- Read
packages/agentickit/src/server/handler.tsto verify the exact option shape and provider registry.