Your first workflow
Time to write some code. Create a file vendor-screen.ts in your tutorial directory:
touch vendor-screen.ts
We'll build it up in three parts: a tool that looks up vendor data, an agent that reasons about it, and a workflow that wires them together.
Part 1 — the tool
A tool is a typed function the agent can call. It has a name, a description (which the LLM sees and uses to decide when to call it), input and output schemas, and the function itself.
For the tutorial we'll use hardcoded vendor data. In real life this would query an internal API, an MCP server, or a SaaS connector.
import { tool } from '@culvii/kit'
import { z } from 'zod'
const FAKE_VENDORS: Record<string, { name: string; rating: number; flags: string[] }> = {
V001: { name: 'Acme Supplies', rating: 4.5, flags: [] },
V002: { name: 'Sketchy Surplus', rating: 1.8, flags: ['previous_dispute'] },
V003: { name: 'Global Widgets Ltd', rating: 3.9, flags: [] },
}
const lookupVendor = tool({
name: 'lookup-vendor',
description: 'Look up a vendor by ID. Returns name, rating (0–5), and any compliance flags.',
input: z.object({
vendorId: z.string().describe('The vendor ID, e.g., V001'),
}),
output: z.object({
name: z.string(),
rating: z.number(),
flags: z.array(z.string()),
}),
execute: async ({ vendorId }) => {
const vendor = FAKE_VENDORS[vendorId]
if (!vendor) throw new Error(`Vendor ${vendorId} not found`)
return vendor
},
})
A few things worth noting:
- The description is for the LLM. Make it specific and actionable. The agent will use this text to decide when to call this tool.
executeis async. It runs on Culvii's cloud at runtime, not on your laptop. See Primitives at a glance for why.- Errors thrown here become tool errors in the trace. The agent sees a structured error and decides what to do (retry, give up, etc.).
Part 2 — the model and the agent
An agent is a configured reasoner. It has a model, a goal, a list of tools, and a typed result schema.
Add to the same file:
import { agent, model } from '@culvii/kit'
const vendorAnalyst = agent({
name: 'vendor-analyst',
goal: `Decide whether a vendor passes basic risk screening.
Pass if rating ≥ 3.5 and no compliance flags.
Fail otherwise. Always explain your reasoning.`,
model: model('anthropic/claude-sonnet-4-20250514'),
tools: [lookupVendor],
result: z.object({
decision: z.enum(['pass', 'fail']),
reason: z.string(),
}),
})
The result schema is enforced — Culvii uses it to validate the agent's final output. If the agent's last response doesn't conform, the run fails with a structured error rather than passing through bad data.
Part 3 — the workflow
A workflow wraps the agent in a deterministic graph. For this tutorial it's a one-step graph; once you're comfortable you'll add branches, gates, and parallelism.
import { workflow, step } from '@culvii/kit'
export const vendorScreen = workflow({
name: 'vendor-screen',
input: z.object({ vendorId: z.string() }),
steps: [
step('analyze', { agent: vendorAnalyst, output: 'screening' }),
],
})
The workflow:
- Declares its input schema (so callers know what to send).
- Runs one step: invoke the
vendorAnalystagent. - Stores the agent's output under the key
screeningin the workflow state. - Returns when the step completes.
The full file
Put together, your vendor-screen.ts looks like:
import { agent, tool, model, workflow, step } from '@culvii/kit'
import { z } from 'zod'
const FAKE_VENDORS: Record<string, { name: string; rating: number; flags: string[] }> = {
V001: { name: 'Acme Supplies', rating: 4.5, flags: [] },
V002: { name: 'Sketchy Surplus', rating: 1.8, flags: ['previous_dispute'] },
V003: { name: 'Global Widgets Ltd', rating: 3.9, flags: [] },
}
const lookupVendor = tool({
name: 'lookup-vendor',
description: 'Look up a vendor by ID. Returns name, rating (0–5), and any compliance flags.',
input: z.object({
vendorId: z.string().describe('The vendor ID, e.g., V001'),
}),
output: z.object({
name: z.string(),
rating: z.number(),
flags: z.array(z.string()),
}),
execute: async ({ vendorId }) => {
const vendor = FAKE_VENDORS[vendorId]
if (!vendor) throw new Error(`Vendor ${vendorId} not found`)
return vendor
},
})
const vendorAnalyst = agent({
name: 'vendor-analyst',
goal: `Decide whether a vendor passes basic risk screening.
Pass if rating ≥ 3.5 and no compliance flags.
Fail otherwise. Always explain your reasoning.`,
model: model('anthropic/claude-sonnet-4-20250514'),
tools: [lookupVendor],
result: z.object({
decision: z.enum(['pass', 'fail']),
reason: z.string(),
}),
})
export const vendorScreen = workflow({
name: 'vendor-screen',
input: z.object({ vendorId: z.string() }),
steps: [
step('analyze', { agent: vendorAnalyst, output: 'screening' }),
],
})
Wait — you didn't run it
Right. Read that again carefully: nothing about this file runs the workflow. There's no vendorScreen.run() call, no await. The file exports a workflow definition. The Culvii engine will run it, in the cloud.
This is the configuration-vs-execution split from Primitives at a glance. Your TypeScript code describes a workflow; it doesn't execute one.
To actually run the thing, you push it to your dev environment. That's the next step: Iterate with culvii dev.