Guard the Spend
Purpose and Scope
Guard the Spend is the tutorial step where the analytics assistant learns to pause before running an expensive query. The reader problem is practical: warehouse-backed agents can produce useful analysis, but a single unfiltered query can scan a very large amount of data and create unexpected cost. This step introduces a human-in-the-loop gate that lets cheap work continue automatically while requiring a person to approve costlier work before the tool executes. Sources: docs/tutorial/guard-the-spend.mdx
The key concept is the tool approval field. In the tutorial, approval runs before execute, receives the proposed tool input, and returns a status that tells eve whether the call should continue, be skipped, or park the run for user approval. Returning "user-approval" parks the active turn on an approval request. After the user answers, eve resumes the run from that exact tool step rather than starting the whole request over. Sources: docs/tutorial/guard-the-spend.mdx
This page covers the cost-control tutorial pattern, not a complete billing-policy system. The example deliberately keeps the run_sql tool on the sample dataset from the earlier tutorial step so the approval behavior can be exercised locally. The same shape is intended to transfer to a real warehouse connection, where the cost estimate would normally come from a dry-run byte estimate instead of a toy SQL heuristic. Sources: docs/tutorial/guard-the-spend.mdx
Relevant Source Files
- docs/tutorial/guard-the-spend.mdx — Defines the Guard the Spend tutorial step, the cost-estimator example, the
run_sqlapproval hook, the expected stream events, continuation behavior, and links to the next tutorial step.
Core Primitives
The tutorial uses an authored tool named run_sql as the control point. An authored tool is a typed function exposed to the model through defineTool, with a description, an input schema, optional policy hooks, and an execute function. In this step, the model can still propose SQL, but the framework gets a chance to inspect the proposed SQL before the query function calls the sample database. That separation is important because approval is attached to the tool boundary, not hidden inside model instructions.
The approval callback is the policy hook. It receives an object containing toolInput, which means the policy can examine the actual SQL the model wants to run. The tutorial’s policy compares estimateScanGb(toolInput?.sql ?? "") with THRESHOLD_GB. If the estimate is above the threshold, it returns "user-approval"; otherwise it returns "not-applicable". In this vocabulary, "not-applicable" means the approval gate is not needed for this call, so execution can proceed normally.
The continuation is the durable parked state created when approval is required. A continuation lets the same run wait for outside input, then resume at the suspended step with the user’s decision. The tutorial states that each session has exactly one active continuation, and that answering an approval against a stale handle is rejected. That prevents double-resuming an already-answered approval and gives application UIs a clear safety boundary for approval buttons or forms.
Implementation Details
The example starts with a small estimator module under agent/lib/cost.ts. Its implementation is intentionally illustrative: a SQL string containing a where clause is treated as a 1 GB scan, while an unfiltered query is treated as a 200 GB scan. This is not presented as production cost modeling; it is a local teaching aid that makes the approval branch easy to trigger without requiring a warehouse account. In a real integration, the same decision point should use the warehouse’s dry-run or planning API.
// agent/lib/cost.ts
export function estimateScanGb(sql: string): number {
return /\bwhere\b/i.test(sql) ? 1 : 200;
}The run_sql tool then imports both the database runner and the estimator. Its input schema is z.object({ sql: z.string() }), which gives the model one explicit field to provide. The constant THRESHOLD_GB is set to 50, so the tutorial’s unfiltered 200 GB estimate requires approval while the filtered 1 GB estimate does not. The execute implementation remains focused on data access: it runs read-only SQL, returns columns, caps rows to 500, and reports whether the result was truncated.
// agent/tools/run_sql.ts
import { defineTool } from "eve/tools";
import { z } from "zod";
import { runReadOnlySql } from "../lib/sample-db.js";
import { estimateScanGb } from "../lib/cost.js";
const THRESHOLD_GB = 50;
export default defineTool({
description: "Run a read-only SQL query against the analytics tables.",
inputSchema: z.object({ sql: z.string() }),
approval: ({ toolInput }) =>
estimateScanGb(toolInput?.sql ?? "") > THRESHOLD_GB ? "user-approval" : "not-applicable",
async execute({ sql }) {
const { columns, rows } = await runReadOnlySql(sql);
return { columns, rows: rows.slice(0, 500), truncated: rows.length > 500 };
},
});A useful design detail is that approval is expressed outside execute. This keeps the executor’s responsibility narrow and makes the policy visible at the tool definition boundary. It also lets the runtime pause before the expensive operation happens. If the cost is below the threshold, the call runs straight through; if the cost is above the threshold, the approval gate trips before any query is sent to the database runner.
Execution Flow
To test the gate, the tutorial asks for a broad unfiltered result: Total revenue across all customers, all time, broken out by day. That prompt encourages the model to propose a query that lacks a restrictive filter, causing the toy estimator to return the expensive estimate. The approval callback returns "user-approval", so the turn parks instead of immediately invoking execute. At that point, the user experience is channel-specific, but the runtime behavior is the same.
The stream emits input.requested, then session.waiting. Those events tell a client or channel adapter that the run is not finished; it is waiting for the user’s approval input. In the local terminal UI this can appear as buttons, in Slack it can be rendered through Block Kit, and on the web it can be represented as a custom UI control. The channel changes presentation, but not the underlying approval lifecycle.
If the user approves, the run resumes from exactly the parked step and the query runs. If the user denies, the tool is skipped and the model is told why. This distinction is important for agent behavior: approval is not just a UI confirmation; it becomes structured control flow in the run. The model can continue with the information that the tool did not run, rather than silently failing or pretending the query succeeded.
System-to-Code Mapping
| Tutorial concern | Source-level construct | Behavior |
|---|---|---|
| Estimate query cost | agent/lib/cost.ts with estimateScanGb(sql) | Classifies filtered queries as cheap and unfiltered queries as expensive for the local demo. |
| Gate the tool | approval inside defineTool | Runs before execute and can return "user-approval" or "not-applicable". |
| Run the query | execute({ sql }) in run_sql | Calls runReadOnlySql, returns columns, capped rows, and a truncation flag. |
| Resume safely | Session continuation | Allows one active parked approval per session and rejects stale approval handles. |
Next Steps
After this step, the tutorial moves to shipping the agent. Before deploying a warehouse-backed assistant, replace the toy estimator with a real cost or policy check, decide which tools or connection operations require approval, and make sure the channel UI clearly displays what the user is approving. The same approval machinery also supports the built-in ask_question tool for mid-turn questions and connection-level approval through approval: once(), so the pattern generalizes beyond SQL spend control.
Read next: tutorial-ship-it for the deployment step, and tools-and-approvals for the broader human-in-the-loop tool model.