Skip to content

Commit 222849a

Browse files
D-K-PTrigger.dev RepoOps
authored andcommitted
docs: add Grok AI Teammates example project
Add an example project page for Grok AI Teammates, an open-source Next.js app by Code With Antonio. Each teammate gets its own cloud sandbox and desktop, runs durable chat turns on `chat.agent()`, hands off work in group chats, and meters sandbox usage with a scheduled task. The page embeds the build video and links to the project's repository. Mono-RevId: f723993ceaa240422464614404896347206a70d5
1 parent 70825ec commit 222849a

3 files changed

Lines changed: 307 additions & 0 deletions

File tree

‎docs/docs.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,7 @@
610610
"guides/example-projects/clickhouse-chat-agent",
611611
"guides/example-projects/cursor-background-agent",
612612
"guides/example-projects/elevenlabs-voice-agent",
613+
"guides/example-projects/grok-ai-teammates",
613614
"guides/example-projects/human-in-the-loop-workflow",
614615
"guides/example-projects/mastra-agents-with-memory",
615616
"guides/example-projects/meme-generator-human-in-the-loop",
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
---
2+
title: "Grok AI Teammates: chat agents with their own cloud computer"
3+
sidebarTitle: "Grok AI Teammates"
4+
description: "An open-source Grok Bot alternative by Code With Antonio. Each AI teammate gets its own Linux sandbox and desktop, runs durable chat turns on Trigger.dev chat.agent(), hands off work in group chats, and meters usage with a scheduled task."
5+
---
6+
7+
<iframe
8+
width="100%"
9+
height="315"
10+
src="https://www.youtube.com/embed/mQBuZ6Dp0u0"
11+
title="How I Build AI Teammates With Claude Code"
12+
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
13+
referrerPolicy="strict-origin-when-cross-origin"
14+
allowFullScreen
15+
/>
16+
17+
<Note>
18+
This open-source project and video are by [Code With Antonio](https://github.com/code-with-antonio),
19+
and are featured here with their permission. The code lives in their GitHub repository.
20+
</Note>
21+
22+
## Overview
23+
24+
Grok AI Teammates is an open-source, fullstack app where each bot has a name, a job and a persistent cloud computer of its own. A bot works in its sandbox through shell, file, mouse and keyboard tools, and you can watch its desktop live in the chat. Every chat turn runs as a durable [chat agent](/ai-chat/overview) on Trigger.dev, so a long desktop task survives page refreshes, redeploys and closed tabs.
25+
26+
**Tech stack:**
27+
28+
- **[Trigger.dev AI chat](/ai-chat/overview)** for the durable chat agent, and a [scheduled task](/tasks/scheduled) that meters running sandboxes
29+
- **[Next.js](https://nextjs.org)** with [`useTriggerChatTransport`](/ai-chat/frontend) and the [AI SDK](https://ai-sdk.dev) `useChat` hook
30+
- **[Daytona](https://www.daytona.io)** for one cloud sandbox per bot: a shell, a filesystem and a desktop the bot controls
31+
- **[Clerk](https://clerk.com)** for authentication, plus Billing for plans and feature gates
32+
- **[Neon](https://neon.com)** for serverless Postgres (with [Drizzle ORM](https://orm.drizzle.team)) and the AI gateway the model is called through
33+
- **[Sentry](https://sentry.io)** for errors, traces and logs from the app and every task run
34+
35+
**Features:**
36+
37+
- **A computer per teammate**: each bot gets a persistent Linux sandbox. Files it leaves there are still there next time.
38+
- **Live desktop view**: watch a bot move the mouse and type, streamed into the chat over noVNC.
39+
- **Group chats with handoffs**: a `handoff` tool passes the rest of a turn to the teammate whose job it is. A request can't bounce back to a bot that already held it.
40+
- **Memory across chats**: `remember` and `recall` tools read and write a notes file in the bot's sandbox, shared by every conversation the bot is in.
41+
- **Durable turns**: the turn loop, streaming and transcript storage all run in a `chat.agent()` task on Trigger.dev.
42+
- **Plans and usage limits**: model spend is recorded per turn in `onTurnComplete`; sandbox time is metered every 5 minutes by a scheduled task that stops sandboxes once an owner runs out.
43+
44+
## GitHub repo
45+
46+
<Card
47+
title="View the AI Teammates repo"
48+
icon="GitHub"
49+
href="https://github.com/code-with-antonio/ai-teammates"
50+
>
51+
Click here to view the full code for this project on GitHub. It's maintained by Code With Antonio,
52+
and you can fork it and use it as a starting point for your own project.
53+
</Card>
54+
55+
## How it works
56+
57+
```mermaid
58+
flowchart LR
59+
U([You]) --> APP[Next.js app]
60+
APP -- start session / stream --> AGENT[chat agent]
61+
AGENT -- shell, files, mouse, keyboard --> SANDBOX[Daytona sandboxes]
62+
APP -- live desktop --> SANDBOX
63+
METER[meter-sandboxes<br/>every 5 min] -- list running / stop --> SANDBOX
64+
AGENT --> DB[(Neon Postgres)]
65+
METER --> DB
66+
```
67+
68+
### The chat agent
69+
70+
One `chat.agent()` answers every chat, direct or group. The browser sends the `botId` a message is addressed to as typed [client data](/ai-chat/server-chat), and the agent loads that bot's job and instructions from the database on every turn, so edits to a bot apply to the next message.
71+
72+
Tools are built per chat in the `tools` callback: every bot gets its sandbox tools, and group chats also get the `handoff` tool. Declaring them on the agent config keeps screenshot tool results as images when earlier turns are replayed. The `run` function returns the [managed `streamText`](/ai-chat/backend#the-managed-streamtext) with 2 stop conditions: a 50-step cap for desktop work, and the user's remaining model budget.
73+
74+
```ts trigger/chat.ts
75+
import { chat } from "@trigger.dev/sdk/ai";
76+
import { isStepCount } from "ai";
77+
import { z } from "zod";
78+
79+
const clientDataSchema = z.object({ botId: z.string().optional() }).optional();
80+
81+
export const chatAgent = chat
82+
.withUIMessage<ChatUIMessage>()
83+
.withClientData({ schema: clientDataSchema })
84+
.agent({
85+
id: "chat",
86+
// Declared here so screenshots stay images when earlier turns are replayed
87+
tools: async ({ chatId, clientData }) => {
88+
const { chat, bots, bot } = await getChatBots(chatId, clientData?.botId);
89+
90+
// Whoever holds the turn; a handoff moves it, and the sandbox tools with it
91+
let current = bot;
92+
const sandboxTools = createSandboxTools(async () => current.sandboxId);
93+
94+
if (chat.kind !== "group") return sandboxTools;
95+
96+
return {
97+
...sandboxTools,
98+
...createHandoffTool((botId) => {
99+
const target = bots.find((bot) => bot.id === botId);
100+
if (!target) throw new Error("No teammate in this chat has that id.");
101+
current = target;
102+
return { botId: target.id, name: target.name };
103+
}),
104+
};
105+
},
106+
// Fires for stopped and failed turns too, which spent tokens all the same
107+
onTurnComplete: async ({ chatId, runId, turn, usage }) => {
108+
const [row] = await db
109+
.select({ userId: chats.userId })
110+
.from(chats)
111+
.where(eq(chats.id, chatId));
112+
113+
if (usage && row) {
114+
await recordUsage([
115+
{
116+
userId: row.userId,
117+
kind: "ai",
118+
amount: chatModelCost(usage),
119+
idempotencyKey: `ai:${runId}:${turn}`,
120+
},
121+
]);
122+
}
123+
},
124+
run: async ({ chatId, clientData, messages, tools, signal, streamText }) => {
125+
const { chat: row, bots, bot } = await getChatBots(chatId, clientData?.botId);
126+
const group = row.kind === "group" ? { chat: row, bots } : undefined;
127+
128+
const usage = await getUsage(row.userId);
129+
if (!usage || usage.remaining.ai <= 0) throw new Error(USAGE_LIMIT_ERROR);
130+
131+
return streamText({
132+
model: chatModel(),
133+
system: buildInstructions(bot, group),
134+
messages,
135+
tools,
136+
// After a handoff, the rest of the turn uses the new bot's instructions
137+
prepareStep: ({ steps }) => {
138+
const handoff = steps
139+
.flatMap((step) => step.toolResults)
140+
.findLast((result) => result.toolName === "handoff");
141+
const current =
142+
handoff && bots.find((bot) => bot.id === (handoff.output as Handoff).botId);
143+
144+
return current ? { instructions: buildInstructions(current, group) } : undefined;
145+
},
146+
stopWhen: [
147+
// Desktop work is one small action per step
148+
isStepCount(50),
149+
// A long turn ends once it has spent what was left
150+
({ steps }) =>
151+
steps.reduce((cost, step) => cost + chatModelCost(step.usage), 0) >=
152+
usage.remaining.ai,
153+
],
154+
abortSignal: signal,
155+
});
156+
},
157+
});
158+
```
159+
160+
The `idempotencyKey` on each usage record is built from the run ID and turn number, so a retried `onTurnComplete` can't charge the same turn twice.
161+
162+
### Metering sandboxes with a scheduled task
163+
164+
Daytona stops idle sandboxes on its own and doesn't report running time, so the app measures it by looking. A [scheduled task](/tasks/scheduled) runs every 5 minutes, charges each running sandbox's owner for the interval, and stops the sandboxes of owners with no hours left:
165+
166+
```ts trigger/usage.ts
167+
import { schedules } from "@trigger.dev/sdk";
168+
import { inArray } from "drizzle-orm";
169+
170+
const INTERVAL_MINUTES = 5;
171+
172+
export const meterSandboxes = schedules.task({
173+
id: "meter-sandboxes",
174+
cron: `*/${INTERVAL_MINUTES} * * * *`,
175+
maxDuration: 240,
176+
run: async ({ timestamp }) => {
177+
const running = await listRunningSandboxIds();
178+
if (running.length === 0) return;
179+
180+
const owned = await db
181+
.select({ sandboxId: bots.sandboxId, userId: bots.userId })
182+
.from(bots)
183+
.where(inArray(bots.sandboxId, running));
184+
185+
await recordUsage(
186+
owned.map(({ sandboxId, userId }) => ({
187+
userId,
188+
kind: "sandbox" as const,
189+
amount: INTERVAL_MINUTES * 60,
190+
// A retried run meters the same moment again
191+
idempotencyKey: `sandbox:${sandboxId}:${timestamp.toISOString()}`,
192+
}))
193+
);
194+
195+
for (const userId of new Set(owned.map((bot) => bot.userId))) {
196+
const usage = await getUsage(userId);
197+
if (usage && usage.remaining.sandbox > 0) continue;
198+
199+
const sandboxIds = owned
200+
.filter((bot) => bot.userId === userId)
201+
.map((bot) => bot.sandboxId);
202+
await Promise.all(sandboxIds.map((id) => stopBotSandbox(id)));
203+
}
204+
},
205+
});
206+
```
207+
208+
### The Next.js chat UI
209+
210+
The browser talks to the agent directly through [`useTriggerChatTransport`](/ai-chat/frontend). Server actions check that the signed-in user owns the chat and has a plan with the `bots` feature before they start a session or mint a session-scoped access token. Previous messages load with [`useLoadTranscript`](/ai-chat/transcript-storage) from the agent's default transcript storage:
211+
212+
```tsx components/chat.tsx
213+
import { useTriggerChatTransport, useLoadTranscript } from "@trigger.dev/sdk/chat/react";
214+
import type { chatAgent } from "@/trigger/chat";
215+
import { loadTranscript, mintChatAccessToken, startChatSession } from "@/actions/chat";
216+
217+
function Chat({ chatId, bots }: { chatId: string; bots?: ChatBot[] }) {
218+
const transport = useTriggerChatTransport<typeof chatAgent>({
219+
task: "chat",
220+
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
221+
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
222+
});
223+
const { messages, isLoading } = useLoadTranscript(chatId, loadTranscript, { transport });
224+
225+
// useChat only reads its initial messages on mount, so wait for the transcript
226+
if (isLoading) return null;
227+
228+
return (
229+
<ChatMessages
230+
key={chatId}
231+
chatId={chatId}
232+
bots={bots}
233+
initialMessages={messages}
234+
transport={transport}
235+
/>
236+
);
237+
}
238+
```
239+
240+
```ts actions/chat.ts
241+
"use server";
242+
243+
import { auth } from "@trigger.dev/sdk";
244+
import { chat, defaultStorage } from "@trigger.dev/sdk/ai";
245+
import type { chatAgent } from "@/trigger/chat";
246+
247+
const startSession = chat.createStartSessionAction<typeof chatAgent>("chat");
248+
const loadStoredTranscript = chat.createLoadTranscriptAction(defaultStorage);
249+
250+
export async function mintChatAccessToken(chatId: string) {
251+
await assertFeature("bots");
252+
await assertChatOwner(chatId);
253+
254+
return auth.createPublicToken({
255+
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
256+
expirationTime: "1h",
257+
});
258+
}
259+
260+
export async function loadTranscript(params: { chatId: string }) {
261+
await assertChatOwner(params.chatId);
262+
263+
return loadStoredTranscript(params);
264+
}
265+
```
266+
267+
### Running it
268+
269+
You need accounts with Clerk, Neon, Trigger.dev and Daytona (Sentry is optional). Set the variables listed in the repo's README in `.env.local`, including `TRIGGER_SECRET_KEY` and `TRIGGER_PROJECT_REF`, then push the schema and run the app and the tasks in 2 terminals:
270+
271+
```bash
272+
npm run db:push # create the tables
273+
npm run dev # terminal 1: Next.js
274+
npx trigger.dev@latest dev # terminal 2: Trigger.dev tasks
275+
```
276+
277+
To deploy, run `npx trigger.dev@latest deploy` and add the tasks' variables (`DATABASE_URL`, `CLERK_SECRET_KEY`, `DAYTONA_API_KEY` and the Neon AI gateway settings) on the [Environment Variables page](/deploy-environment-variables) of your production environment. The repo's README covers the Clerk Billing setup and hosting the Next.js app.
278+
279+
## Relevant code
280+
281+
- **Chat agent**: [trigger/chat.ts](https://github.com/code-with-antonio/ai-teammates/blob/main/trigger/chat.ts): the `chat.agent()` definition, per-chat tools, handoffs, instructions, and per-turn usage recording
282+
- **Sandbox metering**: [trigger/usage.ts](https://github.com/code-with-antonio/ai-teammates/blob/main/trigger/usage.ts): the scheduled task that charges and stops running sandboxes
283+
- **Global hooks**: [trigger/init.ts](https://github.com/code-with-antonio/ai-teammates/blob/main/trigger/init.ts): Sentry setup with `tasks.onFailure`, `tasks.onWait` and `tasks.onComplete` hooks that flush logs before a run freezes
284+
- **Sandbox tools**: [lib/sandbox-tools.ts](https://github.com/code-with-antonio/ai-teammates/blob/main/lib/sandbox-tools.ts): shell, file, mouse, keyboard, screenshot and memory tools on the bot's Daytona sandbox
285+
- **Handoff tool**: [lib/handoff-tool.ts](https://github.com/code-with-antonio/ai-teammates/blob/main/lib/handoff-tool.ts): passes a group chat turn to another bot
286+
- **Server actions**: [actions/chat.ts](https://github.com/code-with-antonio/ai-teammates/blob/main/actions/chat.ts): session start, token minting and transcript loading, each behind an ownership and plan check
287+
- **Chat UI**: [components/chat.tsx](https://github.com/code-with-antonio/ai-teammates/blob/main/components/chat.tsx): `useTriggerChatTransport`, `useLoadTranscript` and `useChat`
288+
- **Trigger.dev config**: [trigger.config.ts](https://github.com/code-with-antonio/ai-teammates/blob/main/trigger.config.ts): retries, max duration and the Sentry source map build extension
289+
290+
## Learn more
291+
292+
<CardGroup cols={2}>
293+
<Card title="AI chat overview" icon="message-bot" href="/ai-chat/overview">
294+
How chat agents, sessions, and the turn loop work.
295+
</Card>
296+
<Card title="Frontend" icon="browser" href="/ai-chat/frontend">
297+
The chat transport, session tokens, and reconnection.
298+
</Card>
299+
<Card title="Tools" icon="wrench" href="/ai-chat/tools">
300+
Declaring tools on your agent and how they persist across turns.
301+
</Card>
302+
<Card title="Scheduled tasks" icon="clock" href="/tasks/scheduled">
303+
Run tasks on a cron schedule.
304+
</Card>
305+
</CardGroup>

‎docs/guides/introduction.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Example projects are full projects with example repos you can fork and use. Thes
6161
| [ClickHouse chat agent](/guides/example-projects/clickhouse-chat-agent) | Build a chat agent that answers questions about your ClickHouse data with charts, tables and maps using `chat.agent()` and generative UI. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/clickhouse-chat-agent) |
6262
| [Cursor background agent](/guides/example-projects/cursor-background-agent) | Run Cursor's headless CLI agent as a background task, streaming live output to the browser. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/cursor-cli-demo) |
6363
| [ElevenLabs Voice agent](/guides/example-projects/elevenlabs-voice-agent) | Hold a spoken conversation with a voice assistant using streaming ElevenLabs speech-to-text and text-to-speech, on a Trigger.dev `chat.agent()`. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/voice-agent-demo) |
64+
| [Grok AI Teammates](/guides/example-projects/grok-ai-teammates) | Open-source AI teammates (by Code With Antonio) that each get a cloud sandbox and desktop, hand off work in group chats, and run durable turns on `chat.agent()`. | Next.js | [View the repo](https://github.com/code-with-antonio/ai-teammates) |
6465
| [Human-in-the-loop workflow](/guides/example-projects/human-in-the-loop-workflow) | Create audio summaries of newspaper articles using a human-in-the-loop workflow built with ReactFlow and Trigger.dev waitpoint tokens. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/article-summary-workflow) |
6566
| [Mastra agents with memory](/guides/example-projects/mastra-agents-with-memory) | Use Mastra to create a weather agent that can collect live weather data and generate clothing recommendations. | — | [View the repo](https://github.com/triggerdotdev/examples/tree/main/mastra-agents) |
6667
| [OpenAI Agents SDK for Python guardrails](/guides/example-projects/openai-agent-sdk-guardrails) | Use the OpenAI Agents SDK for Python to create a guardrails system for your AI agents. | — | [View the repo](https://github.com/triggerdotdev/examples/tree/main/openai-agent-sdk-guardrails-examples) |

0 commit comments

Comments
 (0)