Bonus · Bonus
Rebuilding the Chapter 14 assistant on eve
31 min read · 22 of 22
What this chapter does
The ADK bonus chapter rebuilt the chapter 14 assistant on Google’s agent kit to show what a framework’s classes replace. This chapter rebuilds it one more time, on eve, Vercel’s framework for durable backend agents, to show what a framework’s runtime replaces. Same four specialists, same role gate, same sandboxed code execution, same input/output behaviour for a research-with-code question. What’s different is the shape of the thing you author.
ADK, like the raw build, is a library: you import LlmAgent and AgentTool, compose objects in code, and own the process they run in. eve inverts that. An agent is a directory. The instructions are a markdown file, each tool is a file, each specialist is a subdirectory, the sandbox is a file, the cron schedule is a file, and eve compiles the directory into a deployable service with an HTTP API, a streaming protocol, durable sessions, and a sandbox already attached. You don’t write a runner, a server, or a loop. There is no equivalent of chapter 14’s server.ts because the framework is the server.
One honesty note before we start: eve is in preview (this chapter was built against eve 0.27), so expect surface details to drift. The shape of the ideas is the durable part, which is also the argument of the whole book.
The code lives in code/chapter-bonus-eve/. Two terminals:
$ cd code/chapter-bonus-eve && npm install
$ npm run dev # terminal 1: the dev server (add --no-ui to skip the TUI)
$ npm run ask -- dev hiro "What is BM25 and how does the k1 saturation parameter behave? Show me a short example."
dev may call execute_code; user is denied before the tool runs; a caller with no role gets something neither earlier build could do, which we’ll get to. And by the end of the chapter the same directory is deployed to Vercel and answering DMs in Slack, approval buttons included.
The filesystem is the framework
Here is the entire agent:
agent/
├── agent.ts # model + session token limits
├── instructions.md # the orchestrator's always-on prompt
├── channels/
│ ├── eve.ts # who may call the HTTP routes, and as whom
│ └── slack.ts # the agent inside Slack, via Vercel Connect
├── hooks/audit.ts # the audit log, as a stream-event subscriber
├── tools/
│ ├── agent.ts # disable the built-in self-delegation tool
│ ├── bash.ts # disable the root shell (there's a story here)
│ ├── deliver_brief.ts # Slack delivery via Vercel Connect
│ └── workflow.ts # opt in to model-authored orchestration
├── subagents/
│ ├── research_scout/ # web_search + http_fetch
│ ├── content_validator/ # returns { ok, gaps } as typed output
│ ├── code_author/ # writes snippets, never executes them
│ └── code_verifier/ # execute_code + role gate + locked-down sandbox
└── schedules/weekly_digest.md # a cron expression and a prompt
evals/
├── evals.config.ts
└── bm25-brief.eval.ts
Paths carry meaning. A file at agent/tools/deliver_brief.ts is the deliver_brief tool; the directory agent/subagents/research_scout/ is the research_scout specialist, exposed to the orchestrator as a callable tool under exactly that name. You never write a name: field, and you never write the wiring that connects a tool to an agent, because location is the wiring.
If this rings a bell, it should. Chapter 5 put the assistant’s standing guidance in AGENTS.md and its optional procedures in skills/ folders following the SKILL.md convention, files the runtime discovers rather than code you register. eve runs on the same idea, then extends it to every capability the agent has. agent/instructions.md is the AGENTS.md of this build, and agent/skills/ accepts the exact SKILL.md standard from chapter 5, progressive disclosure included: eve advertises each skill’s description and the model pulls the full body in with a built-in load_skill tool only when a turn needs it.
The one-for-one swaps against the raw build:
| Chapter 14 (raw SDK) | eve equivalent | What goes away |
|---|---|---|
Custom Agent class wrapping the function-call loop | The harness (framework-owned loop) | ~140 lines |
asTool(name, desc, agent, inputDesc) helper | A directory under agent/subagents/ | ~20 lines, plus the name/description duplication |
Closure-bound makeExecuteCodeTool(role) factory | Approval policy reading session.auth | The factory-per-request pattern |
formatVerifyOutcome (a second Gemini call + Zod schema) | outputSchema on the verifier’s defineAgent | ~25 lines and one model call per verify |
Chapter 12’s Dockerfile + runInDocker() | defineSandbox with networkPolicy: "deny-all" | The image build, the flag soup, the spawn plumbing |
Hand-rolled audit() writes | A hook subscribed to the event stream | Scattered call sites |
server.ts (Hono + SSE from chapter 14) | The built-in session routes + NDJSON stream | The whole file |
Manual Map<userId, count> budget | limits.maxInputTokensPerSession | The map and the check |
role request field threaded through every factory | Route auth mapping the caller to a principal | The threading |
Function calling, the eve way
Chapter 4 established the contract: a tool is a description the model reads, a schema the model fills, and a handler you own. Every framework since has been a different way of writing those three things down. Here is the raw build’s http_fetch (a { declaration, handler } object, chapter 8) re-authored for eve, trimmed:
// agent/subagents/research_scout/tools/http_fetch.ts
import { defineTool } from "eve/tools";
import { z } from "zod";
export default defineTool({
description:
"Fetch a public HTTP/HTTPS URL with a hard 10-second timeout. Returns status, status text, and body (truncated to 1 MiB).",
inputSchema: z.object({
url: z.string().describe("Full http(s) URL to fetch."),
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]).optional(),
}),
async execute({ url, method }) {
const signal = AbortSignal.timeout(10_000);
const response = await fetch(url, { method: method ?? "GET", signal, redirect: "follow" });
// ... 1 MiB capped read, structured return, as in chapter 8 ...
},
});
The filename is the tool name. The Zod schema types the execute input, the same way chapter 3’s responseJsonSchema typed structured output. execute runs in the app runtime with full process.env, not in the sandbox, so secrets and network access live on your side of the boundary. And execute receives a second parameter, ctx, carrying the session (ctx.session.auth, the verified caller), the sandbox handle (ctx.getSandbox()), and the token broker (ctx.getToken()), which is what dissolves the raw build’s closure factories: chapter 14 wrapped tool creation in makeExecuteCodeTool(role) because handlers had no way to see per-request context. Here the context comes to the handler.
The default harness
Chapter 7 argued the harness, not the model, is where agent quality lives, and named its four parts: brain, hands, session, feedback. eve calls its built-in loop exactly that, the harness, and its default tool set is a compressed inventory of the hands: bash, read_file, write_file, glob, grep (all targeting the agent’s sandbox), web_fetch, provider-managed web_search, a durable todo list, ask_question for mid-turn clarification, and an agent tool for delegating to a copy of itself. Compaction, chapter 7’s answer to context rot, is on by default: approach the window and eve summarises older turns behind a checkpoint prompt, re-injecting the todo list so the task list survives the squeeze.
You shape that surface per agent with two moves. Author a file at a built-in’s slug and your definition overrides it (spread the default from eve/tools/defaults to wrap it with logging or a guard). Export a sentinel and the built-in is removed:
// agent/tools/bash.ts
import { disableTool } from "eve/tools";
export default disableTool();
That file exists in this build because of something that happened during a live run, and it’s the best argument for chapter 11’s defense-in-depth I can offer from primary experience.
The bypass the audit log caught
First run with the user role, which the verifier’s gate denies. The orchestrator accepted the denial, then quietly ran the snippet anyway, through its own built-in bash tool, in its own sandbox, where no gate existed, and presented the output as “execution proof”. The audit hook told the story plainly:
{"event":"tool_result","isError":false} <- the verifier's typed denial
{"event":"tool_result","tool":"bash","isError":false} <- ...and yet
{"event":"tool_result","tool":"bash","isError":false}
(The denial line carries no tool field: the hook logs result.toolName, and directly delegated subagent results don’t carry one, so the verifier shows up unnamed. Root bash lines right after an anonymous subagent result are the tell.)
Nothing misbehaved: every layer did what it was configured to do; the configuration just left a second door open. A capability you gate in one place must not exist ungated in another; chapter 11 said it about tool authorisation and prompt-level rules, and it’s just as true when the framework ships generous defaults. The fix is the four-line disableTool() above (and its twin on the scout, whose child-session stream showed it fetching pages with python instead of respecting http_fetch’s timeout and size caps; the root audit hook never sees subagent turns, so that one couldn’t show up in the log). Review a framework’s built-ins with the same suspicion you’d review your own tool list; the convenience that makes the quickstart magical is attack surface in an agent with narrower intentions.
Subagents: agent-as-tool as a directory
Chapter 6’s pattern 9 wrapped an agent as a tool; chapter 14 built the whole assistant out of it; the ADK chapter compressed the wrapper to new AgentTool({ agent }). eve compresses it to a location. A directory under agent/subagents/ is a specialist, and eve lowers it into the parent’s tool list automatically. The only mandatory ceremony is a description, because that’s what the orchestrator’s model reads when it decides to delegate, and the compiler rejects a subagent without one. Chapter 4’s lesson that the description is the API, enforced at build time:
// agent/subagents/research_scout/agent.ts
import { google } from "@ai-sdk/google";
import { defineAgent } from "eve";
export default defineAgent({
description:
"Gathers research sources for a given topic via live web search and HTTP fetch. Send one focused research query per call. Returns ~150 words of findings with at least two source URLs.",
model: google("gemini-3.5-flash"),
});
(defineAgent is the same helper the root uses; being under subagents/ is what makes it a child. We pass a provider-authored model from @ai-sdk/google to keep the book’s Gemini through-line; a plain string like "anthropic/claude-sonnet-5", eve’s default, would route through the Vercel AI Gateway instead, and on Vercel needs no API key at all.)
Every subagent tool takes the same input shape, { message, outputSchema? }, and the isolation is total in a way worth pausing on. A declared subagent inherits nothing: not the root’s instructions, not its tools, not its connections, not its sandbox, not its state. Its directory is its whole world. That’s chapter 8’s handoff discipline (the sub-agent gets a task description, not your conversation) hardened from a convention you maintain into a boundary the runtime enforces, and it’s why the orchestrator’s instructions end with the same rule the chapter 8 build carried: every message to a specialist must be self-contained.
The envelope, without the second model call
This is the best structural delta in the whole rebuild. Chapter 13 needed the verifier to return a typed { outcome, user_message } envelope, and paid for it with formatVerifyOutcome, a second constrained Gemini call. The ADK rebuild paid differently: outputSchema and tools couldn’t coexist on one LlmAgent, so the verifier split into a two-agent SequentialAgent pipeline. In eve the verifier declares both, and there is no conflict:
// agent/subagents/code_verifier/agent.ts
export default defineAgent({
description:
"Runs a JS/TS snippet in an isolated sandbox and reports what happened. Returns a typed envelope { outcome: 'executed' | 'denied' | 'error', user_message: string }.",
model: google("gemini-3.5-flash"),
outputSchema: z.object({
outcome: z.enum(["executed", "denied", "error"]),
user_message: z.string(),
}),
});
A delegated subagent runs in what eve calls task mode: it works (calls its tools, uses its sandbox), finishes, and its final answer must match the schema, which the parent receives as a structured tool result. The content_validator gets the same treatment for its { ok, gaps } verdict. Two envelopes, zero extra model calls, zero pipeline agents. Chapter 3’s thesis, that structured output is the interface doing the real work between model steps, is here a one-field declaration.
One operational note that saves you a debugging session: the parent’s stream doesn’t interleave the child’s every event; it records subagent.called (with the child’s own session id) and subagent.completed. Each child is a full durable session you can stream independently, which is chapter 14’s per-event trace made queryable per specialist.
The role gate becomes an approval policy
Chapter 11 drew the line between authentication (who is calling) and authorisation (what they may do), and chapter 14 enforced the latter with an inline if (role !== "dev") in the tool factory. In eve the two halves land in two named places.
Who is calling: route auth, on the HTTP channel. The book’s role argument (a CLI flag in chapters 8 through 13, a JSON body field in chapter 14’s server) becomes a verified property of the request:
// agent/channels/eve.ts
function roleHeaders(): AuthFn<Request> {
return (request) => {
const userId = request.headers.get("x-user-id");
if (!userId) return null; // skip; fall through to localDev()
const role = request.headers.get("x-role") === "dev" ? "dev" : "user";
return {
authenticator: "app",
principalId: userId,
principalType: "user",
attributes: { role },
};
};
}
export default eveChannel({ auth: [roleHeaders(), localDev()] });
auth is an ordered walk: each entry accepts the caller, skips to the next, or rejects with a status. A header check is a stand-in you’d never ship (anyone can type a header); in production that entry is your app’s session lookup, an OIDC verifier, or an API-key store, and everything downstream is unchanged, because downstream code only ever sees the resulting principal on ctx.session.auth.
What they may do: an approval policy on the tool itself. This is where the rebuild stops being a port and becomes an upgrade:
// agent/subagents/code_verifier/tools/execute_code.ts
approval: ({ session }) => {
const role = session.auth.current?.attributes.role;
if (role === "dev") return "not-applicable";
if (role === "user") {
return {
type: "denied",
reason: "permission_denied: role 'user' cannot call execute_code",
};
}
return "user-approval";
},
The first two branches are chapter 14’s gate, verbatim in spirit: dev runs, user gets a typed denial the model reads and works around (the verified caller propagates into the delegated child session, so the policy sees the same role the root did). The third branch is new capability. "user-approval" parks the turn rather than failing the call. eve emits an input.requested event carrying the pending tool call, the session suspends durably at session.waiting, holding no compute, and it stays resumable for seconds or days, across restarts and redeploys. When an answer arrives, the run continues from exactly where it stopped. Captured live, from a caller with no role at all:
$ node hitl.mjs
[session wrun_01KYQ718N44RCMR5Y0C3975DXW] (no role headers; localDev principal)
-> research_scout
-> content_validator
-> code_verifier
[HITL] parked. prompt: "Approve tool call: execute_code"
[HITL] tool: execute_code input: {"language":"js","snippet":"console.log(6 * 7);"}
[HITL] approving request aitxt-MfJaHCC9naHekSGm7RpQ45uW...
### Technical Research Brief: Executing console.log(6 * 7)
...
> "The JavaScript snippet executed successfully. The output shows the
> calculation of 6 * 7, resulting in 42. The execution finished with exit code 0"
The client answered with inputResponses: [{ requestId, optionId: "approve" }]; a plain follow-up message saying “approve” resolves too, and every channel renders the same request natively (in Slack it’s buttons). This pause-durably-and-resume shape is the piece the raw build had no answer for. Chapter 14’s process either finished a request or lost it; a human-in-the-loop gate would have meant holding an HTTP connection open while a human made up their mind. The helpers always(), once(), and never() cover the static cases, and the same approval field works on connections, gating every tool an external server contributes.
The remaining chapter 11 layers stay honest: eve gives you authorisation, isolation, and audit; the input and output content filters (prompt-injection triage, PII redaction) are still yours to write, at the channel boundary or around the model call. No framework in this book’s orbit ships those judgments, and you should distrust one that claims to.
Sandboxes
Chapter 12 spent a chapter earning docker run --network=none --read-only --cap-drop=ALL. This build’s equivalent, in full:
// agent/subagents/code_verifier/sandbox.ts
import { defaultBackend, defineSandbox } from "eve/sandbox";
export default defineSandbox({
backend: defaultBackend({
vercel: { networkPolicy: "deny-all" },
docker: { networkPolicy: "deny-all" },
}),
});
Every eve agent has exactly one sandbox, a bash environment rooted at /workspace, and it exists by default with nothing to author; this file exists only to block egress on whichever backend the host resolves. The built-in shell and file tools already target it, and authored tools reach it through the context:
// agent/subagents/code_verifier/tools/execute_code.ts (the handler)
async execute({ language, snippet }, ctx) {
const sandbox = await ctx.getSandbox();
const file = `snippets/turn-${ctx.session.turn.sequence}.${language === "ts" ? "ts" : "mjs"}`;
await sandbox.writeTextFile({ path: file, content: snippet });
// Old flag spelling on purpose: valid on every Node 24.x sandbox image,
// including ones pulled before the 24.12 rename to --strip-types.
const runner = language === "ts" ? "node --experimental-strip-types" : "node";
const result = await sandbox.run({ command: `timeout 30 ${runner} ${file}` });
return { ok: true, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
}
Compare that to chapter 12’s runInDocker(): the write-then-execute shape is identical, but the container lifecycle, the workspace mount, and the isolation flags belong to the framework. The backend is swappable behind one interface: vercel() runs Vercel Sandbox microVMs, docker() drives a local daemon, microsandbox() runs lightweight local VMs, justbash() is a dependency-free simulated shell for CI, and defaultBackend() picks the best available (Vercel Sandbox when deployed there, Docker locally when a daemon is reachable). Chapter 12’s isolation-tier table, but as a config value, and not hypothetically: every local run in this chapter executed the verifier’s snippets in a Docker container, and the deployed Slack runs below executed the same tool code in Firecracker-class Vercel Sandbox microVMs, with no change to the tool.
Three deltas from the chapter 12 build deserve attention.
Placement. In chapter 12 the sandbox was a tool’s private implementation detail. Here it’s a per-agent facility with a lifecycle: /workspace persists across turns of the same durable session (the verifier can re-run yesterday’s snippet; note that each schedule fire starts a new session, so the digest below gets a fresh sandbox every week), templates capture bootstrap work (bootstrap runs once per template for dependency installs; onSession runs once per session for per-user setup and network policy), and each subagent’s sandbox is its own: the verifier’s lockdown doesn’t constrain the scout, and nothing the snippet does can touch the orchestrator’s files.
Network policy as data. "deny-all" here is chapter 12’s --network=none. On the vercel() and microsandbox() backends policy goes further, to domain-level allow-lists with credential brokering: the firewall injects an auth header for one named host, so a sandboxed process can call an API it holds no credentials for. Chapter 11’s “the model never holds the secret” extended to “the sandbox never holds it either”.
The trust boundary is architectural. Tools, hooks, and connection clients run in the app runtime with the secrets; the sandbox runs model-directed compute with none of them. The two meet only through the handle. That’s chapter 7’s brain/hands boundary, enforced by the runtime rather than by convention.
Workflows: the durability you didn’t write
The word “workflow” does double duty in eve, and both meanings pay off a chapter.
Every turn is a durable workflow. Under the hood each turn runs on the open-source Workflow SDK (Vercel Workflow when deployed there; a local on-disk world in dev). Work nests in three levels: the session (the whole conversation, which can span days), the turn (one user message and everything it triggers), and the step (one model call plus its tool calls), and eve checkpoints at every step boundary. Kill the process mid-turn, redeploy, or hit a timeout, and the run resumes from the last completed step; completed steps are never re-executed, their recorded results replay. A step interrupted mid-flight does re-run, which is why the docs and this chapter both tell you to make non-idempotent side effects idempotent or gate them behind approval, chapter 11’s transactional caution restated as a replay rule.
Set that against what chapter 14 shipped: a Hono process whose in-flight state was the process. The observability was real, the budgets were real, but durability was “don’t crash”. The parked approval above, the days-long sessions, the mid-deploy resume, all of it is this substrate, and none of it appears in the authored code. That’s the trade the preface promised you’d learn to price: fourteen chapters of visible moving parts for two files of config, in exchange for trusting a runtime you didn’t write.
The Workflow tool: plans as programs. Chapter 13 built a Planner that emitted a JSON step list and an Executor that ran it; chapter 14 replaced the fixed plan with per-turn autonomy and noted the cost, that a purely reactive orchestrator can’t fan out deterministically. eve’s opt-in Workflow tool splits the difference in a way neither of the book’s builds reached: the model writes the plan as a JavaScript program, and the runtime executes the program as one durable step.
// agent/tools/workflow.ts
import { experimental_workflow } from "eve/tools";
export default experimental_workflow({ maxSubagents: 6 });
The program runs in a QuickJS sandbox with nothing bridged in except tools.<subagent>(...) functions and language built-ins: no filesystem, no network, no process, by construction rather than by blocklist. It can Promise.all a fan-out, feed one result into the next call, and combine results, the exact orchestration grammar chapter 13’s executor hand-implemented. maxSubagents caps the blast radius, the tool is root-only so programs can’t recurse, and a parked child parks the whole program durably rather than crashing it.
I didn’t have to construct a demonstration. Asked the BM25 question, the orchestrator’s first move, unprompted beyond one instruction hint, was to author a program fanning two scouts in parallel:
$ npm run ask -- dev hiro "What is BM25 and how does the k1 saturation parameter behave? Show me a short example."
[session wrun_01KYQ53G2YW0REZ96Q9CPRY5NE] role=dev user=hiro
-> research_scout (child wrun_01KYQ53SPNY91W0DWKD4H4G1X1)
-> research_scout (child wrun_01KYQ53SPTZ8NSRT8G1MXCKYH9)
-> content_validator (child wrun_01KYQ5604KBQ3X7N8G4WTPM7R5)
-> research_scout (child wrun_01KYQ568XBPMX1PXA8VZN5Q86N)
-> content_validator (child wrun_01KYQ57CDAQ6E1K100KBW2HKFF)
-> code_author (child wrun_01KYQ57MAG2821SEH274PTKKHT)
-> code_verifier (child wrun_01KYQ57X0W1558SXGABDHW0WHV)
[waiting] 170.2s
Read the trace against chapter 13 and 14’s shapes: a parallel scouting pass (the Workflow program; the audit log records the Workflow call ahead of the twin scouts), a validator verdict, a re-scout targeting the reported gap, re-validation, then the author/verify tail (autonomy). The two-build dichotomy the spine ends on, plan-then-execute versus decide-per-turn, turns out to be a dial, and here the model itself chooses per request where to set it. The final brief quoted the sandbox’s stdout verbatim, TF: 5 | Score: 1.7742 for k1=1.2 against 2.1429 for k1=2.0, saturation demonstrated with executed numbers, Confidence: high.
Vercel Connect: the OAuth chapter the book never had
The running build never had a tool that acts on a third-party service as a signed-in user, and the ADK chapter’s honest note explained the gap: doing it properly means an OAuth dance, encrypted token storage, refresh, and per-user scoping, a stack of undifferentiated plumbing. Vercel Connect is that stack as a managed service, and eve integrates it as a first-class auth provider, so this rebuild finally adds the capability: deliver_brief, which posts the finished brief to Slack.
// agent/tools/deliver_brief.ts
import { connect } from "@vercel/connect/eve";
import { defineTool } from "eve/tools";
import { once } from "eve/tools/approval";
const slackAuth = connect("slack/research-briefs");
export default defineTool({
description: "Post a finished research brief to a Slack channel. Only when the user asked.",
inputSchema: z.object({ channel: z.string(), brief: z.string() }),
approval: once(),
async execute({ channel, brief }, ctx) {
const { token } = await ctx.getToken(slackAuth);
const res = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" },
body: JSON.stringify({ channel, text: brief }),
});
const data = await res.json();
if (!data.ok && (data.error === "invalid_auth" || data.error === "token_revoked")) {
ctx.requireAuth(slackAuth); // evict the dead token, restart consent
}
return { delivered: data.ok, error: data.error ?? null };
},
});
"slack/research-briefs" is a connector UID you register once (vercel connect create slack --name research-briefs from the agent directory; the CLI walks the browser-side app installation). At runtime the flow composes everything this chapter has covered so far:
- The model calls
deliver_brief. Theonce()approval fires first; a human signs off. ctx.getToken(slackAuth)asks Connect for a token for the active session’s user, the principal that route auth attached. First time, there is no grant, so eve emitsauthorization.requiredwith a consent URL, and the turn parks, durably, on the same machinery as an approval.- The user completes OAuth in a browser. Connect stores the tokens encrypted, handles refresh forever after, and the parked turn resumes into
executewith a live token. - The token is cached per step and never serialised into session history; the model never sees it. On a mid-call revocation,
ctx.requireAuthevicts and re-challenges instead of feeding the model a dead-token error.
Approve, then sign in, then act, and eve guarantees you’re never double-prompted: the approval is recorded before the OAuth park, so the resume doesn’t re-ask. Note also what the identity plumbing bought us: because a Connect grant is keyed to the session’s principal, whose Slack the brief posts from is decided by route auth, chapter 11’s authentication layer, not by which token happens to sit in an env var. The alternative shapes are one option away: connect({ connector, principalType: "app" }) makes the agent act as itself (a bot posting to a team channel; no consent flow, and the right choice for anything a schedule triggers, since a cron run has no end-user to send through OAuth, and a user-scoped connection on a background run fails fast with principal_required rather than guessing).
The same helper family covers the other direction, and this build ships it live. eve’s channels put the agent inside Slack, Discord, Teams, Telegram, GitHub, or Linear; the whole Slack side of this agent is one file:
// agent/channels/slack.ts
import { connectSlackCredentials } from "@vercel/connect/eve";
import { slackChannel } from "eve/channels/slack";
export default slackChannel({
credentials: connectSlackCredentials("slack/research-briefs"),
});
connectSlackCredentials wires both the outbound bot token and inbound webhook verification through Connect, so there is no SLACK_BOT_TOKEN or signing secret anywhere in the project. DM the bot (or mention it in a channel) and the channel maps the Slack sender to a user principal automatically; the agent replies in a thread, surfaces its progress (you’ll watch the todo tool’s plan updates scroll by), and renders HITL prompts as native Approve/Deny buttons. The chapter 14 question of “what does the frontend for this look like” gains the answer “the chat tool your team already runs”.
Getting there produced one more field lesson worth the ink. Slack events reach your deployment through a Connect trigger: Slack delivers to Vercel, Vercel verifies and forwards to the eve route. Two consequences. First, inbound Slack needs a real deployment; the local dev server can’t receive forwarded webhooks. Second, triggers are part of the connector’s Slack app registration: my first connector was created without --triggers (it was outbound-only at the time), and the resulting Slack app was minted with no event subscriptions at all. The symptom was perfect silence: the bot online, the DM sent, and the deployment logs showing zero requests to /eve/v1/slack, because Slack had nothing telling it to send any. No error anywhere, since every component was doing exactly what it was configured to do; the second silent-door lesson of this chapter, diagnosed the same way (go where the audit trail should be and notice it’s empty). The fix: recreate with vercel connect create slack --name research-briefs --triggers, then re-point the trigger at eve’s route, vercel connect detach slack/research-briefs followed by vercel connect attach slack/research-briefs --triggers --trigger-path /eve/v1/slack (the detach matters: create provisions a trigger destination at the default Connect path, which eve doesn’t serve). After that, each DM landed as a POST /eve/v1/slack → 200 in the logs and a threaded reply in Slack.
Connections generalise past Slack: point defineMcpClientConnection at any MCP server (the MCP bonus chapter’s protocol, with eve as the host: a built-in connection_search tool exposes the server’s tools by qualified name) or defineOpenAPIConnection at any OpenAPI document, attach connect(...) or a getToken of your own, and the external service’s tools join the agent’s surface with the credentials brokered outside the model’s reach.
Memory, evals, schedules, and the deploy
Memory (chapter 9). The one component this rebuild deliberately does not port, and the framing matters. Chapter 9 built two layers: episodic recall within a relationship, and a durable user profile. The first layer eve simply dissolves: a session is a durable conversation that survives restarts and can span weeks, with compaction managing the window, so “remember what we discussed” is simply what a session already is. Typed working memory on top of that is defineState, a named durable slot with get()/update() that outlives crashes (never shared with subagents, matching everything else about their isolation). The second layer, cross-session per-user memory, the SQLite profile with embedding recall and reconciliation, remains yours: keyed by principalId in your own store, or via a memory-focused connection. The framework gives you identity and durability; what’s worth remembering about a person is still a product decision, which is exactly where chapter 9 left it.
Evals (chapter 10). eve’s runner drives the same HTTP surface users hit and asserts on what the agent actually did, and it earned its keep in this build within the hour:
// evals/bm25-brief.eval.ts
export default defineEval({
description: "The orchestrator researches and validates before answering.",
async test(t) {
await t.send("What does the k1 parameter do in BM25, and what do current search engines default it to? Cite at least two current sources. No code needed.");
t.succeeded();
t.calledTool("research_scout");
t.calledTool("content_validator").soft();
t.check(t.reply, includes("BM25"));
},
});
The first version of that eval asked a softer question, and failed honestly: observed tools: [todo, todo, todo]. Gemini judged the question answerable from its own weights and skipped the scout entirely, exactly the ungrounded-shortcut failure mode chapter 10 told you to write regression checks for, caught by the check, on day one. (Also a quiet lesson from chapter 2: an instruction that says “always research first” competes with a prompt that whispers “this one’s easy”, and loses often enough to need enforcement.) Assertion severity is graded per line: t.succeeded() and calledTool("research_scout") are hard gates, and the thresholdless .soft() validator check is tracked as a metric without ever failing the build. Give a soft check a bar (.soft(1), .atLeast(0.8)) and eve eval --strict promotes its misses to failures in CI.
$ npx eve eval --url http://localhost:2000
✓ bm25-brief gates 3/3 calledTool(content_validator): 0%
Results: 1 passed (1 total)
Gates: 3 passed
That trailing 0% is the soft check doing its job: this run skipped the validator, the eval still passed, and the miss landed as a metric to watch instead of a broken build.
Schedules. Chapter 14’s production posture assumed a request-driven service. agent/schedules/weekly_digest.md, a cron expression in frontmatter and a prompt as the body, runs the agent on its own clock; on Vercel each schedule becomes a Vercel Cron Job. Scheduled runs execute in task mode (run to completion, no parking for humans, which is precisely why their external actions should be app-scoped and approval-exempted deliberately, per the Connect section). In dev, schedules never fire on cadence; a one-shot dispatch route triggers them on demand.
Observability and cost (chapter 14). The audit hook covers the book’s jq-queryable log, but the platform surface goes further: every session is a streamable event log, hooks subscribe to it in-process, and deployed on Vercel the Agent Runs tab under Observability lets you browse sessions and inspect each conversation trace with zero authored code (the tab needs enablement for your Vercel team; ask your Vercel contact if it doesn’t appear). The cost triangle’s budget leg is limits in agent.ts: past the cap eve pauses the session with an explicit approve-more-spend prompt rather than a mystery failure, and delegated children draw down shares of the parent’s budget so a fan-out can’t outspend its root.
A2A (chapters 8 and 13). The Translator service the book ran as a separate JSON-RPC process maps to defineRemoteAgent: another eve deployment called as a subagent over HTTP, with the caller’s identity optionally forwarded and verified against an explicit trust list on the receiving end. Same discovery-and-delegation shape, authenticated by construction.
The deploy. Done for real for the Slack channel above: vercel link, vercel env add GOOGLE_GENERATIVE_AI_API_KEY production, then VERCEL_USE_EXPERIMENTAL_FRAMEWORKS=1 vercel deploy --prod (eve link / eve deploy wrap the same steps). The build output wires the session routes to the web runtime, turns durable sessions over to Vercel Workflow, registers the schedule with Vercel Cron, and resolves the sandbox to Vercel Sandbox. Chapter 14’s server.ts, SSE streaming, and process supervision have no equivalent files to write; a string-model-id agent wouldn’t even need the API key step, since gateway routing authenticates through the project’s OIDC identity.
Side-by-side, by line count
| Surface area | Chapter 14 raw | This bonus | Delta |
|---|---|---|---|
| Agent class / runtime | 140 (agent.ts) | 0 (the harness) | -140 |
| Agent-as-tool wrapper | 18 (asTool) | 0 (directory placement) | -18 |
| Orchestrator | ~50 (Agent + wrapped specialists) | 48 (agent.ts + instructions.md) | similar |
| Specialists | ~80 (4 Agents + factories) | ~180 (4 dirs: config + instructions + bash disables) | more files, mostly instruction text |
| Verifier envelope | 25 (Zod + formatVerifyOutcome + wrapper) | 4 (outputSchema field) | -21 and one model call per verify |
| Tool authz | inline role checks | 10 (one approval policy) | localised; gains the HITL branch |
| Sandbox | Dockerfile + runInDocker (chapter 12, ~90) | 12 (sandbox.ts) + the same write/run handler | -80 |
| Audit log | 30 (call sites throughout) | 36 (one hook) | similar; now guaranteed consistent with history |
| Session/serve/CLI | ~30 runner + server.ts (chapter 14) | 0 authored (+41-line optional CLI client) | the framework is the server |
| Memory layer | 120 (SQLite + embeddings + reconcile) | not ported (sessions are durable; profiles stay external) | see above |
| Total, comparable surface | ~780 | ~330 | -450 |
The full directory weighs ~500 authored lines, but the extra ~170 are capabilities the raw build never had: a live Slack presence with managed OAuth in both directions, the Workflow tool, a cron schedule, a regression eval, route auth, and a human-approval path. The honest comparison is both numbers side by side: less code for the same behaviour, and the frontier moved.
Action
- Set up
code/chapter-bonus-eve/:npm install, putGOOGLE_GENERATIVE_AI_API_KEYin.env, have Docker running,npm run dev. - Run the BM25 question as
dev. Watch for a doubled-> research_scoutat the top of the trace; that’s the model choosing a Workflow fan-out. Find the sandbox container while it runs:docker psshows the verifier’s session container on theghcr.io/vercel/eveimage. - Run the 2+2 question as
user, then readaudit.log. Then deleteagent/tools/bash.ts, run it again, and watch the orchestrator rediscover the bypass; restore the file and appreciate chapter 11 from the framework side. - Run
node hitl.mjs, and while it sits parked at “Approve tool call”, kill the dev server, restart it, and then approve. The turn finishes anyway. That’s the durability section, felt. - Run
npx eve eval --url http://localhost:2000. Soften the question inbm25-brief.eval.tsback to “What is BM25? Two sentences, no code.” and watch the research gate catch the model answering from memory. - Go live in Slack. Register the connector with triggers (
vercel connect create slack --name research-briefs --triggersfrom the project directory; without the flag the Slack app has no event subscriptions and your DMs go nowhere, silently), re-point the trigger at eve’s route (vercel connect detach slack/research-briefs --yes, thenvercel connect attach slack/research-briefs --triggers --trigger-path /eve/v1/slack),vercel link, add the Gemini key to the project env, andVERCEL_USE_EXPERIMENTAL_FRAMEWORKS=1 vercel deploy --prod. Then DM the bot the BM25 question and watch the approval buttons appear in the thread. Ask it to deliver a brief and you’ll hit thedeliver_briefOAuth consent too. - Read
node_modules/eve/docs/. The full documentation ships inside the package, versioned with the code, and it is the source of truth this chapter was checked against.
Three builds of the same assistant now sit in the repo: by hand, on ADK, on eve. The hand build taught you what every component is; ADK showed the components as a class library; eve shows them as a runtime you author files into. Whichever kit your stack lands on, the fourteen chapters are why none of it is a black box.