Tutorial: Build a Chat Agent
Build a persistent CLI agent that talks to the user, remembers context across turns, and can be extended with custom tools. By the end you'll understand how persistent mode works, how channels route messages, how session middleware gives agents memory, and how the REPL provides a polished interactive experience.
What You'll Build
A conversational assistant that:
- Runs as a persistent agent — it never terminates, waiting for input between turns
- Manages its own context window so long conversations don't overflow
- Remembers previous conversations across process restarts via session middleware
- Can be extended with custom tools that the agent discovers and uses
Prerequisites
- Node.js 22+
- AWS credentials with Bedrock access — see AWS & Bedrock Setup
Step 1: The Simplest Agent
The simplest possible agent is a single file. No skills directory, no package.json, no configuration beyond the prompt.
Create a directory and a SKILL.md:
mkdir chat-agent && cd chat-agent
---
name: chat
description: Persistent CLI chat agent
allowed-tools: "gateway-send gateway-receive file-read file-write shell"
metadata:
role: main
model:
id: us.anthropic.claude-sonnet-4-6
region: us-east-1
persistent: true
contextEdit: true
---
You are a helpful assistant. Talk to the user. You can read and write files and run shell commands when asked.
Run it:
agent-apps
You'll see the agent start and wait for input:
> How are you?
I'm doing well! How can I help you today?
> What files are in this directory?
⠋ Thinking...
✓ file-list — Completed in 0.1s
There's just one file here: SKILL.md — that's the skill file that defines me.
> Create a file called hello.txt with "Hello, World!" in it
⠋ Thinking...
✓ file-write — Completed in 0.1s
Done! I've created hello.txt with "Hello, World!" in it.
Press Ctrl-C to stop the agent.
What's Happening
Let's break down each piece of the SKILL.md:
role: main — This marks the file as the project's main skill. When you run agent-apps with no arguments, this is what executes. It's also the authority for configuration — all other skills in the project inherit from it.
persistent: true — This is the key to a chat agent. Normally, an agent runs one turn (prompt → tool calls → response) and stops. With persistent: true, the hook callback returns { continue: true } at the end of every turn, so the agent loops forever. Each turn, it reads from gateway-receive (stdin by default), processes the input, and writes to gateway-send (stdout by default).
contextEdit: true — LLMs have a finite context window. In a long conversation, the accumulated messages eventually exceed it. Context editing lets the agent manage this: it can remove old messages, summarize earlier parts of the conversation, or trim tool results to stay within limits. Without this, a long conversation would eventually fail with a context length error.
allowed-tools — Controls which tools the agent can use. gateway-send and gateway-receive are how the agent talks to the user. file-read, file-write, and shell give it filesystem and command access. The agent sees these tools in its system prompt and can call them through code.
The prompt — The markdown body after the frontmatter. This becomes the agent's system prompt. It tells the agent what it is and what it can do. The agent interprets this at runtime — change the prompt, change the behavior.
How Channels Work
When you run from the CLI, the framework automatically sets up a gateway on nonlocals.gateway:
{
name: "cli",
description: "Command-line interface",
send: "cli-send-a1b2c3d4", // callback ref → writes to stdout
receive: "cli-receive-e5f6g7h8", // callback ref → reads from stdin
}
When the agent calls gateway-send, the framework dispatches to the gateway's send ref, which writes to stdout. When it calls gateway-receive, the framework dispatches to the receive ref, which reads a line from stdin. The agent doesn't know it's talking to a terminal — it just uses the gateway. If you later connect this same skill to Slack or email, the gateway routes to a different transport, but the agent code doesn't change.
Step 2: Use the REPL
The raw agent works, but the experience is basic — no streaming, no history, no way to cancel a turn mid-flight. The REPL skill wraps the agent with a polished interactive interface:
agent-apps repl
> What's 2 + 2?
> 4. Anything else?
> /help
Available commands:
/help Show this help
/clear Clear conversation history
/history Show conversation history
/debug Toggle debug mode
/exit Exit the REPL
> /clear
Conversation cleared.
The REPL provides:
- Streaming output — you see the agent's response as it's generated, not all at once
- Readline history — press up/down to recall previous inputs, persisted across sessions
- Tab completion — for slash commands
- Ctrl-C handling — first press cancels the current turn (the agent stops mid-response), second press exits
- Spinner — shows activity while the agent is thinking or calling tools
- Tool call display — shows which tools the agent calls and how long they take
Under the hood, the REPL creates an event topic for agent activity, subscribes a renderer to it, and sets up a channel that routes through readline instead of raw stdin/stdout. The agent itself is the same — the REPL just provides a better shell around it.
You can also start the REPL with an initial task:
agent-apps repl --task "Summarize the files in this directory"
This sends the task as the first message, so the agent starts working immediately.
Step 3: Add Session Memory
Stop the agent (Ctrl-C twice) and restart it. Ask "What did we talk about last time?" — it won't know. Each process start is a fresh conversation. Session middleware fixes this.
Update SKILL.md:
---
name: chat
description: Persistent CLI chat agent
allowed-tools: "gateway-send gateway-receive file-read file-write shell"
metadata:
role: main
model:
id: us.anthropic.claude-sonnet-4-6
region: us-east-1
persistent: true
contextEdit: true
session:
key: "nonlocals.gateway.id"
store: file
maxEntries: 100
---
You are a helpful assistant. Talk to the user. Remember context from previous conversations.
Now restart:
agent-apps
Have a conversation, then stop and restart. The agent remembers what you discussed:
> My name is Alice and I'm working on a Python project.
Nice to meet you, Alice! What kind of Python project are you working on?
[Ctrl-C, restart]
> What's my name?
Your name is Alice! You mentioned you're working on a Python project.
How Session Works
The session middleware intercepts the pipeline at two points:
Before the agent runs — it loads previous conversation entries from storage and prepends them to the prompt. The agent sees a history section at the top of its system prompt showing what happened in previous sessions.
After the agent finishes a turn — it captures the current turn's data (args, result, and optionally the agent trace) and saves it to storage.
key: "nonlocals.gateway.id" — This is the correlation key. The middleware calls pathGet(ctx, "nonlocals.gateway.id") to get a value that identifies the current user. For CLI, this is typically your system username. For Slack, it would be the Slack user ID. For email, the sender address. Each unique key gets its own conversation history.
store: file — Saves history to .agent-apps/sessions/ as JSON files. The alternative is memory (default), which only lasts for the process lifetime — useful for long-running servers but lost on restart.
maxEntries: 100 — Rolling window. When the history exceeds 100 entries, the oldest are dropped. This prevents unbounded growth.
What Gets Captured
By default (capture: [$all]), the session captures:
$args— what the user sent$result— what the agent returned$agent— the agent's internal trace (tool calls, reasoning)
You can be selective: capture: [$args, $result] skips the agent trace (smaller storage). Or capture specific fields: capture: ["args.message", "result.summary"].
Step 4: Add Custom Tools
The agent can use any skill in the project. Let's add a note-taking tool.
Create the skills directory and a note skill:
mkdir -p skills notes
Create skills/note.skill.js:
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
export const frontmatter = {
name: 'note',
description: 'Save or read a named note. Notes are markdown files stored in the notes/ directory.',
metadata: {
params: {
type: 'object',
properties: {
action: { type: 'string', description: 'save or read' },
name: { type: 'string', description: 'Note name (without extension)' },
content: { type: 'string', description: 'Note content (for save action)' }
},
required: ['action', 'name']
}
}
};
export default async function(ctx, { action, name, content }) {
const path = resolve(ctx.locals.config.cwd, 'notes', `${name}.md`);
if (action === 'save') {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content, 'utf-8');
return `Saved note: ${name}`;
}
try {
return await readFile(path, 'utf-8');
} catch {
return `Note not found: ${name}`;
}
}
Update SKILL.md to add the skills directory to the search path and include note in allowed tools:
---
name: chat
description: Persistent CLI chat agent with notes
allowed-tools: "gateway-send gateway-receive file-read file-write shell note"
metadata:
role: main
paths: [./skills]
model:
id: us.anthropic.claude-sonnet-4-6
region: us-east-1
persistent: true
contextEdit: true
session:
key: "nonlocals.gateway.id"
store: file
maxEntries: 100
---
You are a helpful assistant with a note-taking system. You can save and retrieve notes for the user. Use the note tool to manage notes in the notes/ directory.
Now the agent discovers and uses the note tool:
> Save a note called "meeting" with today's action items: review PR, update docs, deploy v2
⠋ Thinking...
✓ note — Completed in 0.1s
Done! I've saved your meeting notes.
> What were my meeting action items?
⠋ Thinking...
✓ note — Completed in 0.1s
Your meeting action items were:
1. Review PR
2. Update docs
3. Deploy v2
How Tool Discovery Works
When you added paths: [./skills] and note to allowed-tools, two things happened:
Search path — The
pathsmiddleware adds./skillsto the directories the runtime searches for tools. The runtime discoversnote.skill.jslazily — it doesn't scan at startup, it finds the tool when something asks for it.Agent tool list — The
agentskill filters available tools by theallowed-toolsspec. It findsnote(because it has params and matches the spec), reads its name, description, and params, and includes it in the system prompt. The agent sees something like:
**note**(action: "save"|"read", name: string, content?: string) — Save or read a named note.
The agent then calls the tool through code:
const result = await ctx.manager.invoke("note", { action: "save", name: "meeting", content: "..." });
This goes through the full pipeline — schema validation, middleware, trust (if configured) — just like any other skill invocation.
Step 5: Add Trust for Dangerous Tools
The agent can run shell commands and write files. In a personal CLI tool that's fine, but if you're building something others will use, you might want approval before destructive operations.
Add trust to your SKILL.md metadata:
metadata:
trust:
rules: "file-write shell"
store: file
Now when the agent tries to write a file or run a shell command, it asks for permission:
> Delete all .tmp files in this directory
🔒 Tool requires approval: shell
Args: {"command":"find . -name '*.tmp' -delete"}
Allow? [y/n/a/v]
- y (yes) — allow this one time
- a (always) — always allow this tool (saved to
.agent-apps/trust.json) - n (no) — deny this time
- v (never) — always deny this tool
Trust decisions with store: file persist across restarts. The note tool isn't in the trust rules, so it runs freely — trust only gates the tools you specify.
What You Built
chat-agent/
SKILL.md ← Main skill: agent config + prompt
skills/
note.skill.js ← Custom note-taking tool
notes/ ← Note storage (created at runtime)
.agent-apps/
sessions/ ← Conversation history (created by session middleware)
trust.json ← Trust decisions (created by trust middleware)
What You Learned
- A single SKILL.md is a complete agent. The prompt is the system prompt, the metadata is the configuration. No boilerplate.
persistent: truecreates a chat loop. The agent runs turn after turn, reading fromgateway-receiveand writing togateway-send. Without it, the agent runs once and exits.contextEdit: truemanages long conversations. The agent can trim its own context window to stay within model limits.- Channels abstract the transport. The agent uses
gateway-send/gateway-receivewithout knowing whether it's talking to a terminal, Slack, or email. The gateway middleware routes to the right transport. - Session middleware gives agents memory. It captures conversation data, stores it (in memory or on disk), and re-injects it into the prompt on the next invocation. The correlation key determines who gets which history.
- Custom tools are just skills. Add a
.skill.jsfile to the search path, include it inallowed-tools, and the agent discovers and uses it automatically. - Trust gates dangerous operations. The trust middleware prompts for approval before tools you specify, with persistent decisions.
- The REPL is the polished version. Streaming, history, tab completion, Ctrl-C handling — all built on the same agent underneath.
Next Steps
- Slack Bot Tutorial — Connect the same agent to Slack with trust and roles
- Email Agent Tutorial — Build a long-running email assistant
- Cookbook — Agent — Subagents, coordination, custom providers
- Cookbook — Session — Session configuration details
- Cookbook — Trust — Trust rule syntax and patterns