Build the smallest end-to-end agent: one tool, one model, one run, and a verified hash chain.
What you'll build: a runnable greeter agent with one custom tool whose hash chain verifies end-to-end.
Prerequisites: Quickstart for install, and a Node 20+ project with TypeScript strict mode.
Next: add human-in-the-loop oversight.
mkdir my-first-agent && cd my-first-agent
npm init -y
npm install @fuze-ai/agent zod
npm install -D typescript @types/node tsx
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --strict true
Create src/greet.ts:
import { z } from 'zod'
import { defineTool, Ok, type ThreatBoundary } from '@fuze-ai/agent'
const threatBoundary: ThreatBoundary = {
trustedCallers: ['agent-loop'],
observesSecrets: false,
egressDomains: 'none',
readsFilesystem: false,
writesFilesystem: false,
}
export const greet = defineTool.public({
name: 'greet',
description: 'returns a greeting',
input: z.object({ name: z.string() }),
output: z.object({ greeting: z.string() }),
threatBoundary,
retention: {
id: 'demo.v1',
hashTtlDays: 30,
fullContentTtlDays: 7,
decisionTtlDays: 90,
},
run: async (input) => Ok({ greeting: `hello, ${input.name}` }),
})
Real agents use a real provider. For this tutorial, use a scripted model that returns predetermined steps. Create src/model.ts:
import type { FuzeModel, ModelStep } from '@fuze-ai/agent'
export const scriptedModel = (steps: readonly ModelStep[]): FuzeModel => {
let i = 0
return {
providerName: 'fake',
modelName: 'demo-1',
residency: 'eu',
generate: async () => {
const s = steps[i++]
if (!s) throw new Error('model exhausted')
return s
},
}
}
Create src/index.ts:
import { z } from 'zod'
import {
defineAgent,
inMemorySecrets,
runAgent,
StaticPolicyEngine,
verifyChain,
makeTenantId,
makePrincipalId,
} from '@fuze-ai/agent'
import { greet } from './greet.js'
import { scriptedModel } from './model.js'
const agent = defineAgent({
purpose: 'demo-greeter',
lawfulBasis: 'consent',
annexIIIDomain: 'none',
producesArt22Decision: false,
model: scriptedModel([
{
content: '',
toolCalls: [{ id: 'c1', name: 'greet', args: { name: 'world' } }],
finishReason: 'tool_calls',
tokensIn: 10,
tokensOut: 5,
},
{
content: '{"final":"hello, world"}',
toolCalls: [],
finishReason: 'stop',
tokensIn: 12,
tokensOut: 4,
},
]),
tools: [greet],
output: z.object({ final: z.string() }),
maxSteps: 5,
retryBudget: 0,
deps: {},
})
const records: any[] = []
const policy = new StaticPolicyEngine([
{ id: 'allow.greet', toolName: 'greet', effect: 'allow' },
])
const result = await runAgent(
{ definition: agent, policy, evidenceSink: (r) => records.push(r) },
{
tenant: makeTenantId('demo-tenant'),
principal: makePrincipalId('demo-user'),
secrets: inMemorySecrets({}),
userMessage: 'please greet world',
},
)
console.log({
status: result.status,
output: result.output,
hashChainHead: result.evidenceHashChainHead,
hashChainValid: verifyChain(records),
})
Expected output:
{
"status": "ok",
"output": { "final": "hello, world" },
"hashChainHead": "<sha256>",
"hashChainValid": true
}
If hashChainValid is false, your records were re-ordered or mutated. Every span goes through EvidenceEmitter; the chain is non-bypassable.
Next: add human-in-the-loop oversight.