Skip to content
bits
Your first agent loop · Lesson 4 of 9 · 25 min

Talk to Klippy from your terminal

Desk: best at a computer, with a terminal

Verified agents 0.33.0ai 7.0.162tsx 4.23.15

Start from c-03 on GitHub. Starting here, or behind? Get the code at that tag:

Shell
git clone https://github.com/ryanyogan/klippy
cd klippy
git checkout c-03
Tutor:

Klippy is an agent now, but nothing talks to it. This lesson gives it a chat in your terminal: pnpm chat sam opens a conversation with the Klippy called sam, and replies stream in word by word.

Tutor:

1. A script again

The chat is a script on your machine, like last lesson's loop, so tsx comes back. From klippy/:

Shell
pnpm --filter agent add -D --save-exact tsx@4.23.15

In apps/agent/package.json, add "chat": "tsx --env-file=.dev.vars scripts/chat.ts" to scripts, and put "scripts/**/*.ts" back in tsconfig.json's include. The script reads KLIPPY_TOKEN from your .dev.vars.

Tutor:

2. Open one WebSocket

A chat is many messages back and forth, with replies arriving in pieces. A WebSocket suits that: one connection, kept open, messages both ways. Make apps/agent/scripts/chat.ts:

import { WebSocketChatTransport } from 'agents/chat/transport';
import { AgentClient } from 'agents/client';
import { readUIMessageStream, type UIMessage } from 'ai';
import { createInterface } from 'node:readline/promises';
// `pnpm chat alice` talks to the Klippy called "alice". KLIPPY_TOKEN comes from .dev.vars.
const url = new URL(process.env.KLIPPY_URL ?? 'http://localhost:8787');
const name = process.argv[2] ?? 'me';
const token = process.env.KLIPPY_TOKEN ?? '';
// One WebSocket to this Klippy, kept open for the whole chat. A WebSocket can't send headers, so the token rides
// in the query string (the Worker allows that only for the socket, and strips it before the agent sees it).
const agent = new AgentClient({ agent: 'Klippy', name, host: url.host, protocol: url.protocol === 'https:' ? 'wss' : 'ws', query: { token } });
const transport = new WebSocketChatTransport({ agent });
TypeScriptUTF-8at c-04● lines 6–14: apps/agent/scripts/chat.ts
Tutor:
  • KLIPPY_URL says where Klippy runs. It defaults to wrangler's port, 8787; set it to your deployed address later.

  • AgentClient opens the socket to /agents/klippy/<name>. The token goes in query, the one place last lesson allowed it.

  • WebSocketChatTransport speaks the chat protocol over that socket.

Tutor:

3. Start from the chat Klippy already has

The conversation lives in the agent, not in your script. So load it first:

// Start from the conversation this Klippy already has (it lives in the agent, not here).
const history = await fetch(new URL(`/agents/klippy/${name}/get-messages`, url), { headers: { Authorization: `Bearer ${token}` } });
if (!history.ok) throw new Error(`Klippy said ${history.status}: is the Worker running, and is KLIPPY_TOKEN right?`);
let messages: UIMessage[] = await history.json();
console.log(`Talking to Klippy "${name}" (${messages.length} messages so far). Ctrl+D to quit.`);
TypeScriptUTF-8at c-04● lines 2–5: apps/agent/scripts/chat.ts
Tutor:

get-messages is a route every chat agent has. It's plain HTTP, so the token goes in the header.

Tutor:

4. Read your lines, send them, print the stream

The rest of the file is the chat itself:

const terminal = createInterface({ input: process.stdin, output: process.stdout });
process.stdout.write('you> ');
for await (const text of terminal) {
if (!text.trim()) { process.stdout.write('you> '); continue; }
messages = [...messages, { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text }] }];
const stream = await transport.sendMessages({ chatId: name, messages, trigger: 'submit-message', messageId: undefined, abortSignal: undefined });
// The reply arrives in chunks. Each step gives the whole message so far: print only what's new.
process.stdout.write('klippy> ');
let printed = 0;
let reply: UIMessage | undefined;
for await (reply of readUIMessageStream({ stream })) {
const text = reply.parts.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('\n');
process.stdout.write(text.slice(printed));
printed = text.length;
}
if (reply) messages = [...messages, reply];
process.stdout.write('\nyou> ');
}
agent.close();
TypeScriptUTF-8at c-04● lines 5–15: apps/agent/scripts/chat.ts
Tutor:
  • Each line you type becomes a user message, added to the history.

  • sendMessages sends the conversation; the reply comes back as a stream of chunks.

  • readUIMessageStream turns the chunks into a growing message. Each step gives the whole reply so far, so the script prints only the new characters (slice(printed)).

The agent stores every reply too: if your connection drops mid-reply, a client that reconnects can pick it up where it left off. This script doesn't show that, but the agent does it for you.

Tutor:

5. Talk to Klippy

Terminal 1, from klippy/: pnpm dev. Terminal 2, in apps/agent:

Shell
pnpm chat sam

Our run. The free Workers AI fallback answered, not Claude (the gateway had no Anthropic key). Our dev server was on port 8801, hence KLIPPY_URL, and we piped the lines in, so they don't echo after you>. Typed by hand, they do.

recorded output
printf "Hi, I am Sam.\nWhat is the weather in Dublin right now?\n" | KLIPPY_URL=http://localhost:8801 pnpm chat sam
Talking to Klippy "sam" (0 messages so far). Ctrl+D to quit.
you> klippy> Hello Sam! I'm Klippy, your friendly personal assistant. How can I help you today?
you> klippy> It's currently 11°C (52°F) in Dublin with clear skies and light winds at 17.6 km/h.
Tutor:

Then a new run with the same name, and one with a different name:

recorded output
printf "What is my name?\n" | KLIPPY_URL=http://localhost:8801 pnpm chat sam
Talking to Klippy "sam" (4 messages so far). Ctrl+D to quit.
you> klippy> Hi Sam!
recorded output
printf "What is my name?\n" | KLIPPY_URL=http://localhost:8801 pnpm chat alex
Talking to Klippy "alex" (0 messages so far). Ctrl+D to quit.
you> klippy> I don't have any information about you or your name. (…)
Tutor:

sam kept its chat across runs; alex is a different Klippy, with its own. That's short-term memory: the chat itself. Next lesson adds memory that outlives it.

If you see Klippy said 401, KLIPPY_TOKEN in .dev.vars doesn't match the one the Worker was started with: restart pnpm dev. If it hangs, check KLIPPY_URL points at the port pnpm dev printed.

No new tests this time: the script is a client, pnpm typecheck checks it, and the agent's tests are last lesson's.

quick check+20 neurons
Tutor:

Why does the script load the chat from the agent before you type?

    Pick one option

    Pick one, then Check.