Building an agent
The Quickstart runs the agent as-is. This guide goes further: you'll build a small, real agent — a code-review bot — that has its own tool, returns structured results, and streams its progress. By the end you'll have used the pieces most agents need.
We'll build it up in steps. The finished script is at the bottom.
1. Create the agent
import { createLilyAgent } from '@poolot/lily-web'
const agent = await createLilyAgent({
apiKey: process.env.POOLOT_API_KEY,
model: 'poolot-standard',
systemPrompt:
'You are a code reviewer. Review the diff the user gives you for correctness, ' +
'missing tests, and error handling. When you are done, call submit_review with your findings.',
})
The systemPrompt sets the agent's job once, so every run starts from the same brief.
2. Give it a tool
The agent can already read and reason. To make it do something specific to your app, add a custom tool. A tool is a name, a description the model reads to decide when to call it, a parameter schema, and a handler that runs your code:
const agent = await createLilyAgent({
apiKey: process.env.POOLOT_API_KEY,
systemPrompt: '...as above...',
tools: [
{
name: 'submit_review',
description: 'Submit the final review once every finding has been collected.',
parameters: {
verdict: 'string', // shorthand: a required string parameter
summary: 'string',
},
// `completesRun` ends the run when the model calls this tool, and the
// tool's ARGUMENTS become the run's structured result — no parsing prose.
lifecycle: { completesRun: true },
handler: (args) => {
// You could also persist the review here. Returning it is enough for now.
return args
},
},
],
})
A few things worth knowing about tools:
- Parameters can be the shorthand above (
{ verdict: 'string' }) or a full JSON Schema. For real validation, pass a Zod schema asinputSchemainstead — arguments are checked before your handler runs, and if they're wrong the model is told what to fix. handlerreturns a string or an object (objects are JSON-stringified for the model). Throwing marks the call as failed, and the model sees the error and can try again.lifecycle.completesRunturns a tool into a typed exit: the run ends andresult.resultholds the arguments — structured data instead of prose you'd have to parse back out.
3. Run it and stream progress
Feed the agent a diff and stream what it's doing so a UI (or your logs) can follow along:
const diff = await readFileOrGitDiff() // your code
const result = await agent.runDetailed(
[{ role: 'user', content: `Review this change:\n\n${diff}` }],
{
onToken: (t) => process.stdout.write(t),
onEvent: (e) => {
if (e.type === 'tool_call') console.log(`\n[tool] ${e.name}`)
},
},
)
onToken streams the model's text; onEvent reports lifecycle events like tool calls. See the events reference for the full list.
4. Read the result
Because submit_review completes the run, the structured findings are on result.result:
if (result.status === 'completed' && result.result) {
const review = result.result // { verdict, summary }
console.log(`\nVerdict: ${review.verdict}`)
console.log(review.summary)
} else {
console.error(`Review did not finish cleanly: ${result.status}`)
}
Always check result.status — a run can end for reasons other than success (it hit a budget, was aborted, or failed). Going to production covers handling every case.
The whole thing
import { createLilyAgent } from '@poolot/lily-web'
const agent = await createLilyAgent({
apiKey: process.env.POOLOT_API_KEY,
model: 'poolot-standard',
systemPrompt:
'You are a code reviewer. Review the diff the user gives you for correctness, ' +
'missing tests, and error handling. When done, call submit_review with your findings.',
tools: [
{
name: 'submit_review',
description: 'Submit the final review once every finding has been collected.',
parameters: { verdict: 'string', summary: 'string' },
lifecycle: { completesRun: true },
handler: (args) => args,
},
],
})
const diff = await readFileOrGitDiff()
const result = await agent.runDetailed(
[{ role: 'user', content: `Review this change:\n\n${diff}` }],
{ onToken: (t) => process.stdout.write(t) },
)
if (result.status === 'completed' && result.result) {
console.log(`\nVerdict: ${result.result.verdict}\n${result.result.summary}`)
} else {
console.error(`Review did not finish: ${result.status}`)
}
console.log(result.usage) // track what it cost
Where to go next
- Make it robust — Going to production: handle every status, cap cost with budgets, gate risky tools with approvals, and wire up logging.
- Understand the stream — Events and messages.
- More patterns — Examples.