Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/skills-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-ax/agents': patch
---

Add skills system for dynamic knowledge loading with use_skill/remove_skill tools, including an interactive tutorial skill
5 changes: 5 additions & 0 deletions .github/workflows/validate-skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ jobs:
# Monorepo — find skills/ under packages
for dir in packages/*/skills; do
if [ -d "$dir" ]; then
# Skip internal skills (not intent skills)
if [ "$dir" = "packages/agents/skills" ]; then
echo "Skipping $dir (internal skills)..."
continue
fi
echo "Validating $dir..."
intent validate "$dir"
fi
Expand Down
282 changes: 282 additions & 0 deletions packages/agents/skills/tutorial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
---
description: Interactive tutorial — build a perspectives analyzer entity with the manager-worker pattern
whenToUse: User asks about building entities, wants a tutorial, is new to Electric Agents, or wants to learn multi-agent patterns
keywords:
- tutorial
- getting started
- learn
- multi-agent
- manager-worker
- perspectives
- entity
user-invocable: true
max: 25000
---

# Tutorial: Build a Perspectives Analyzer

Build a `perspectives` entity that analyzes questions from an optimist and a critic using the manager-worker pattern. Use the exact code below — do not invent different code.

## Core Concepts

### What is Electric Agents?

Electric Agents is a runtime for spawning and orchestrating collaborative AI agents on serverless compute.

The core idea: agent sessions and communication are backed by **durable streams**. Each agent is an **entity** with its own stream of events. All agent activity — runs, tool calls, text output — is persisted to this stream. This means agents can scale to zero, survive restarts, and maintain full session history.

**Why this matters for multi-agent systems**: Because everything is durable and observable, agents can spawn children, wait for results (even across restarts), observe each other's state changes, and coordinate through structured primitives — all without worrying about losing state.

### Entities

An entity is a durable, addressable unit of computation. Each entity has:

- A **type** (e.g., `assistant`, `worker`, `research-team`) — defined once, instantiated many times
- A **URL** (e.g., `/research-team/my-team`) — its unique address
- A **handler** — the function that runs each time the entity wakes up
- **State** — persistent collections that survive across wakes

You define entity types with `registry.define()` and create instances by spawning them.

### Handlers and Wakes

An entity's handler runs in response to **wake events**:

- A message arrives in the entity's inbox
- A child entity finishes its run
- A cron schedule fires
- A state change in an observed entity

The handler is **not** a long-running process. It wakes, does its work (usually running an LLM agent loop), and goes back to sleep.

### The Agent Loop

`ctx.useAgent()` configures an LLM agent and `ctx.agent.run()` starts it. The agent receives conversation history, calls tools as needed, and generates a response — all persisted to the entity's durable stream.

### Spawning Children

Any entity can spawn child entities. When a child finishes (and the parent registered `wake: "runFinished"`), the parent's handler runs again. The wake event includes the child's response and the status of sibling children.

### The Worker Entity

The built-in `worker` type is a generic agent substrate. You configure it at spawn time with a `systemPrompt` and `tools` array (at least one tool required).

### State Collections

Entities can declare persistent state collections that survive across wakes, allowing coordination patterns like tracking which children have completed.

## Before starting

Read `server.ts` in the working directory:

- **Has `registerPerspectives`**: resume from where they left off (read `entities/perspectives.ts` to determine the step)
- **Has `server.ts` but no perspectives**: go to Step 1
- **No `server.ts`**: scaffold the project — spawn a worker (`tools: ["bash"]`, systemPrompt: `"Set up an Electric Agents app project."`, initialMessage: `"mkdir -p TARGET/lib TARGET/entities && cp SKILL_DIR/scaffold/* TARGET/ && cp SKILL_DIR/scaffold/lib/* TARGET/lib/ && cp SKILL_DIR/scaffold/.env TARGET/ && cd TARGET && pnpm install && pnpm dev &"` — replace SKILL_DIR and TARGET). Then proceed to Step 1 while the worker runs. Wait for the worker to finish before writing files.

## Steps

**Step 1 — Welcome + first entity.** In one message: introduce Electric Agents using the Core Concepts above, preview the perspectives analyzer, and show the Step 1 code. Ask to write.

**Step 2 — After confirmation:** write `entities/perspectives.ts` with Step 1 code. Give CLI commands. Explain spawning briefly, show Step 2 code (adds one worker). Ask to write.

**Step 3 — After confirmation:** write the updated file. Give CLI commands. Explain coordination, show Step 3 code (adds critic + state). Ask to write.

**Step 4 — After confirmation:** write the updated file. Give CLI commands.

**Step 5 — Wire up.** Read `server.ts`, show the import change, ask to write, update it.

**Step 6 — Recap.**

## Rules

- Use the exact code below. Write files with your write tool.
- `server.ts` is at the working directory root. Entity files go in `entities/`.
- Worker spawn args MUST include `tools` array (e.g. `tools: ["bash", "read"]`).
- Prefer showing what changed between steps rather than repeating the entire file.
- Use `edit` tool for small changes (like updating server.ts). Use `write` for full entity file updates.

---

# Code

## Step 1: Minimal entity

`entities/perspectives.ts`:

```typescript
import type { EntityRegistry } from '@electric-ax/agents-runtime'

export function registerPerspectives(registry: EntityRegistry) {
registry.define('perspectives', {
description: 'Analyzes questions from multiple perspectives',
async handler(ctx) {
ctx.useAgent({
systemPrompt:
'You are a balanced analyst. When given a question, provide a thoughtful analysis.',
model: 'claude-sonnet-4-6',
tools: [...ctx.electricTools],
})
await ctx.agent.run()
},
})
}
```

`server.ts` additions:

```typescript
import { registerPerspectives } from './entities/perspectives'
registerPerspectives(registry)
```

Test: `pnpm electric-agents spawn /perspectives/test-1 && pnpm electric-agents send /perspectives/test-1 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-1`

## Step 2: One worker

Full `entities/perspectives.ts`:

```typescript
import type {
EntityRegistry,
HandlerContext,
} from '@electric-ax/agents-runtime'
import { Type } from '@sinclair/typebox'

function createAnalyzeTool(ctx: HandlerContext) {
return {
name: 'analyze_question',
label: 'Analyze Question',
description: 'Spawns an optimist worker to analyze a question.',
parameters: Type.Object({
question: Type.String({ description: 'The question to analyze' }),
}),
execute: async (_toolCallId: string, params: unknown) => {
const { question } = params as { question: string }
const parentId = ctx.entityUrl.split('/').pop()
await ctx.spawn(
'worker',
`${parentId}-optimist`,
{
systemPrompt:
'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
tools: ['bash', 'read'],
},
{ initialMessage: question, wake: 'runFinished' }
)
return {
content: [
{
type: 'text' as const,
text: "Spawned optimist worker. You'll be woken when it finishes.",
},
],
details: {},
}
},
}
}

export function registerPerspectives(registry: EntityRegistry) {
registry.define('perspectives', {
description: 'Analyzes questions from multiple perspectives',
async handler(ctx) {
ctx.useAgent({
systemPrompt: `You are a balanced analyst.\n\nWhen given a question:\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken when the worker finishes.\n3. When woken, finished_child.response contains the analysis.\n4. Present it to the user.`,
model: 'claude-sonnet-4-6',
tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
})
await ctx.agent.run()
},
})
}
```

Test: `pnpm electric-agents spawn /perspectives/test-2 && pnpm electric-agents send /perspectives/test-2 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-2`

## Step 3: Two workers + state

Full `entities/perspectives.ts`:

```typescript
import type {
EntityRegistry,
HandlerContext,
} from '@electric-ax/agents-runtime'
import { Type } from '@sinclair/typebox'

const PERSPECTIVES = [
{
id: 'optimist',
systemPrompt:
'You are an optimist analyst. Provide an enthusiastic, positive analysis focusing on opportunities and benefits.',
},
{
id: 'critic',
systemPrompt:
'You are a critical analyst. Provide a sharp analysis focusing on risks, downsides, and challenges.',
},
]

function createAnalyzeTool(ctx: HandlerContext) {
return {
name: 'analyze_question',
label: 'Analyze Question',
description: 'Spawns optimist and critic workers to analyze a question.',
parameters: Type.Object({
question: Type.String({ description: 'The question to analyze' }),
}),
execute: async (_toolCallId: string, params: unknown) => {
const { question } = params as { question: string }
const parentId = ctx.entityUrl.split('/').pop()
for (const p of PERSPECTIVES) {
const childId = `${parentId}-${p.id}`
await ctx.spawn(
'worker',
childId,
{ systemPrompt: p.systemPrompt, tools: ['bash', 'read'] },
{ initialMessage: question, wake: 'runFinished' }
)
ctx.db.actions.children_insert({
row: { key: p.id, url: `/worker/${childId}` },
})
}
return {
content: [
{
type: 'text' as const,
text: 'Spawned optimist and critic workers.',
},
],
details: {},
}
},
}
}

export function registerPerspectives(registry: EntityRegistry) {
registry.define('perspectives', {
description:
'Analyzes questions from two perspectives: optimist and critic',
state: { children: { primaryKey: 'key' } },
async handler(ctx) {
ctx.useAgent({
systemPrompt: `You are a balanced analyst.\n\n1. Call analyze_question with the question.\n2. End your turn. You'll be woken as each worker finishes.\n3. Each wake includes finished_child.response and other_children.\n4. Once both are done, synthesize a balanced response.`,
model: 'claude-sonnet-4-6',
tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
})
await ctx.agent.run()
},
})
}
```

Test: `pnpm electric-agents spawn /perspectives/test-3 && pnpm electric-agents send /perspectives/test-3 "Is remote work better than office work?" && pnpm electric-agents observe /perspectives/test-3`

## What you learned

- `registry.define()` — entity types with description, state, handler
- `ctx.useAgent()` + `ctx.agent.run()` — configure and run an LLM agent
- `ctx.spawn()` — spawn child entities with custom prompts
- Wake events — parents wake when children finish
- State collections — track data across wakes
- The worker pattern — one generic type, many roles
Empty file.
80 changes: 80 additions & 0 deletions packages/agents/skills/tutorial/scaffold/lib/electric-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Type } from '@sinclair/typebox'
import type { AgentTool } from '@electric-ax/agents-runtime'

type CreateElectricToolsContext = {
entityUrl: string
entityType: string
args: Readonly<Record<string, unknown>>
upsertCronSchedule: (opts: {
id: string
expression: string
timezone?: string
payload?: unknown
debounceMs?: number
timeoutMs?: number
}) => Promise<{ txid: string }>
upsertFutureSendSchedule: (opts: {
id: string
payload: unknown
targetUrl?: string
fireAt: string
from?: string
messageType?: string
}) => Promise<{ txid: string }>
deleteSchedule: (opts: { id: string }) => Promise<{ txid: string }>
}

export function createElectricTools(
ctx: CreateElectricToolsContext
): Array<AgentTool> {
return [
{
name: `upsert_cron_schedule`,
label: `Upsert Cron`,
description: `Create or update a recurring cron wake schedule.`,
parameters: Type.Object({
id: Type.String({ description: `Stable schedule identifier` }),
expression: Type.String({ description: `Cron expression` }),
timezone: Type.Optional(Type.String({ description: `IANA timezone` })),
payload: Type.Any({ description: `Instruction for the agent` }),
}),
execute: async (_toolCallId, params) => {
const { id, expression, timezone, payload } = params as any
const tz = timezone ?? `UTC`
const { txid } = await ctx.upsertCronSchedule({
id,
expression,
timezone: tz,
payload,
})
return {
content: [
{ type: `text` as const, text: `Cron "${id}" set. txid=${txid}` },
],
details: { txid },
}
},
},
{
name: `delete_schedule`,
label: `Delete Schedule`,
description: `Delete a schedule by id.`,
parameters: Type.Object({
id: Type.String({ description: `Schedule identifier` }),
}),
execute: async (_toolCallId, params) => {
const { id } = params as { id: string }
const { txid } = await ctx.deleteSchedule({ id })
return {
content: [
{
type: `text` as const,
text: `Schedule "${id}" deleted. txid=${txid}`,
},
],
details: { txid },
}
},
},
]
}
17 changes: 17 additions & 0 deletions packages/agents/skills/tutorial/scaffold/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "my-electric-agents-app",
"private": true,
"type": "module",
"scripts": {
"start": "tsx server.ts",
"dev": "tsx --watch server.ts"
},
"dependencies": {
"@electric-ax/agents-runtime": "latest",
"@sinclair/typebox": "^0.34.49"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
Loading
Loading