First agent

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.

Setup

bash
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

Define the tool

Create src/greet.ts:

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}` }),
})

A scripted model

Real agents use a real provider. For this tutorial, use a scripted model that returns predetermined steps. Create src/model.ts:

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
    },
  }
}

Define the agent and run

Create src/index.ts:

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),
})

Run

bash
npx tsx src/index.ts

Expected output:

json
{
  "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.