buttercup.sh lesson 4

Lesson 4: agent communication - when one agent asks another for help

Welcome back. This time, we'll help an agent share the work with agents built by other people. Together we'll publish an agent-card.json and a handler on Blocks.ai, accept tasks with blocks run, and invite another agent into the handler with ctx.taskClient.sendMessage().

Publish a handler, then ask another agent for help from inside it. The video follows the copyable code below. Watch on YouTube.

Two sides that fit together

There are two small pieces to connect. The provider runs in Node or Python, and browser code calls it through the same SDK. They use different credentials and entry points, so we'll take them one at a time.

the provider half · Node or Python
agent-card.json
handler.jsblocks runholds one outbound
connection while running
the caller half · browser-safe
TaskClient.create()sendMessage()a page, a script,
or another handler
The provider is a long-lived process on your hardware. It reads BLOCKS_API_KEY from the environment, so it cannot run in a browser tab. Browser callers use TaskClient, textPart, and filePart.

Your agent opens an outbound connection and waits for work. It needs no inbound port or DNS record, so it can run from a laptop, a VM behind NAT, or a Raspberry Pi. Blocks requires one outbound TLS connection, which most corporate firewalls allow. how it works shows the same design in a one-page diagram.

blocks network
your laptoporchestrator
your VMline_counter
Tokyosentiment_scan
Berlinlink_check
cataloguepdf_split
cataloguenotes_reviewer
Each agent holds one outbound connection. You own the two on the green rail, including the one behind NAT. The hub routes and queues requests, tracks presence, and keeps agent addresses private. Calling link_check in Berlin adds latency but requires no network configuration.

You can keep the provider and caller in one repository. For Node, install @blocks-network/sdk, version 1.0.19 at the time of writing, plus @blocks-network/cli for the blocks command. The Python package is blocks_network, also 1.0.19. Check the latest release before pinning a version. Both packages live in one open repository, where you can inspect the API.

What the network takes care of

Blocks takes care of the network boundary while you run the application. The Blocks overview documents the split.

The process runs on the machine where you start it. Close your laptop and the agent goes offline. The handler uses your runtime, CPU, and secrets. You write the fan-out, including the loop in A handler that hires agents; see set up agent-to-agent communication. Publishing adds an agent to the catalogue. It does not send callers to you.

the boundary · network services · code you own
the network does this
  • communication across NAT, firewalls and clouds
  • reach: one stable name, no DNS record
  • routing and queueing, load balanced across instances
  • presence: callers learn you are down
  • identity: short-lived per-task JWTs
  • monitoring: every task and interaction tracked
  • streaming, both directions, while the task runs
the parts you shape
  • hosting: the process runs where you started it
  • execution: your runtime, your CPU, your secrets
  • orchestration: the fan-out below is code you own
  • demand: a listing is not a caller
↻ infrastructure Blocks operates ✕ application choices your team gets to make
Blocks operates the infrastructure in the left column. Your team gets to shape the application in the right column.

Blocks routes and queues work across your running instances. You set capacity with concurrency; Blocks manages the queue. Presence tells callers when an agent is down, and streaming carries progress during a task. Blocks supplies agentName, agentId and orgId, carried in signed JWTs, with org-scoped channels and per-task isolation. The key concepts page explains the model for multi-tenant agents. The dashboard tracks tasks and interactions. After a network drop, the SDK reconnects when the machine comes back online. Blocks uses PubNub, fifteen years old and quoted at up to 99.999%; the data plane write-up explains that number, and the architecture overview separates the control plane that registers, authorises and routes, and the developer interface that your code uses.

Any code that accepts a task and returns a result can connect, including a plain function or the loop from lesson 2. Blocks also ships adapters for CrewAI, LangChain, OpenClaw, plus an MCP bridge for calling network agents from an MCP client. The card and handler define the contract. Blocks documents the two roles separately: agent creators publish, app builders call. A handler that hires agents uses both APIs.

An agent card is a tool definition for other agents

Lesson 2 used a local tool definition: a name, description, and JSON Schema for arguments. An agent card publishes the same contract to the network:

lesson 2 · a tool
namedescriptioninput_schema.propertiesthe model picks it
lesson 4 · an agent
identity.agentNameidentity.descriptionio.inputs[].id + schemaanother builder finds it
The card is a public tool description. The caller sends your declared io.inputs[].id as its partId, much as a model uses a schema property name as an argument key. Blocks cards follow the A2A specification with a runtime extension, so the shape ports to other A2A systems.

Descriptions help callers find the right fit. In lesson 2, a vague description could send the model to a less suitable tool. Here, a clear card helps the right callers discover your agent and understand what it can do for them.

This card gives a line-counting agent one narrow job:

{
  "identity": {
    "agentName": "line_counter",
    "displayName": "Line Counter",
    "description": "Counts lines, words and characters in plain text. No LLM, no network, no state.",
    "version": "1.0.0",
    "provider": { "organization": "buttercup.sh" }
  },
  "capabilities": { "taskKinds": ["request"] },
  "io": {
    "inputs": [{
      "id": "request",
      "description": "The text to measure.",
      "contentType": "text/plain",
      "required": true,
      "example": "one\ntwo\nthree"
    }],
    "outputs": [{
      "id": "counts",
      "description": "{ lines, words, chars }",
      "contentType": "application/json",
      "guaranteed": true
    }]
  },
  "tags": [{ "id": "text", "name": "Text", "description": "Measures plain text." }],
  "runtime": {
    "handler": "./handler.js",
    "handlerExport": "default",
    "concurrency": 4,
    "maxRunningTimeSec": 15
  }
}

Pay attention to these four fields:

Bring the handler online

Now for the satisfying part: a handler receives a task and returns one or more artifacts. This first one stays deliberately small.

// handler.js runs under `blocks run` in Node. No framework or base class.
export default async function handler(task, ctx) {
  const text = (task.requestParts ?? [])
    .filter((part) => part.partId === "request")
    .map((part) => part.text)
    .filter(Boolean)
    .join("\n");

  if (!text) {
    // Return a useful artifact when the caller omits the input.
    return { artifacts: [{
      data: JSON.stringify({ error: "no text in the 'request' part" }),
      mimeType: "application/json",
      outputId: "counts",
    }] };
  }

  // This reaches the caller's onProgress handler.
  ctx?.reportStatus(`measuring ${text.length} characters`);

  return { artifacts: [{
    data: JSON.stringify({
      lines: text.split("\n").length,
      words: text.split(/\s+/).filter(Boolean).length,
      chars: text.length,
    }),
    mimeType: "application/json",
    outputId: "counts",     // matches io.outputs[].id, so callers can route it
  }] };
}

The SDK publishes artifacts in array order, then sends the terminal event. It sends artifacts up to 16 KB (16,384 bytes); anything larger takes a pre-signed upload, capped at 25 MB (26,214,400 bytes). The SDK chooses between inline base64 and an upload.

These commands connect the handler to the network. Blocks' quickstart runs the same sequence, and connect your agent covers wrapping an agent you already have rather than a fresh one:

npm install -g @blocks-network/cli     # or: curl -fsSL https://config.blocks.ai/install.sh | sh
blocks init line_counter --mode provider --language node -y
cd line_counter && npm install
blocks login --write-env               # writes BLOCKS_API_KEY into .env
blocks check                           # validates the card before runtime
blocks register                        # private and free, opens the dashboard
blocks run                             # holds the connection; your agent is now reachable

Run blocks check before registration. It catches a missing runtime section, a hyphen in the agent name, or a handler without export default. Otherwise, blocks run reports only [blocks-run] fatal:.

blocks register creates a private, free agent. It is reachable but absent from the catalogue, and incurs no charge. blocks publish lists it publicly and lets you set per-task or per-minute pricing. Publishing also gives the agent a public page with task, response-time, and activity metrics. You can unpublish it. Blocks has no staging environment, so register privately before blocks run connects the agent.

Start with a private rehearsal

There isn't a separate dry-run mode: an agent is live as soon as its process connects. Register it privately, send the first task yourself, and publish when you're happy with the result.

Ask an agent for help from a page

The caller SDK is happy in a browser. Your API key stays safely on the server, and TaskClient gives you three ways to connect the two. (Use agents in your app documents the caller side.)

three auth modes · keep keys off the page
apiKey · server-side only
  1. key in the bundle
  2. the key appears in devtools
  3. ↑ an exposed key and shared quota
✕ correct for a cron job or a backend, never for a page
tokenEndpoint · your proxy mints the JWT
  1. POST /api/blocks-token
  2. your server checks your user
  3. { token, expiresIn, userId }
  4. refreshed at 80% of TTL
↻ the key stays on your box; the page holds a short-lived token
tokenProvider · the same idea as a function
  1. your async fn runs
  2. OAuth, SSO, a native keychain
  3. { token, expiresIn, userId }
↻ for when the token does not come from a URL you can POST to
Use apiKey on a server, never on a page. tokenEndpoint is a URL your own service owns. tokenProvider supplies tokens through a function for OAuth or SSO. All three refresh at 80% of the token's TTL and after a 401.

Set stream: true in the browser client to receive progress. The server does not stream by default.

// consumer.js is safe to ship to a browser. No key in it.
import { TaskClient, textPart } from "https://esm.sh/@blocks-network/sdk@1.0.19";

const client = await TaskClient.create({
  billingMode: "free",                 // must match how the agent registered
  tokenEndpoint: "/api/blocks-token",  // your server, holding your key
  onAuthError: (err) => log("auth: " + err.message),
});

const session = await client.sendMessage({
  agentName: "line_counter",
  requestParts: [textPart(document.getElementById("goal").value, "request")],
  stream: true,                        // opt in; the server default is off
});

session.onProgress((event) => log("· " + event.message));
session.onError((err, where) => log(`! ${where.callbackType}: ${err.message}`));

// One await instead of a callback tangle. Rejects on timeout.
const terminal = await session.waitForTerminal(90_000);
log("[ " + terminal.state + " ]");

for (const ref of session.listArtifacts()) {
  const { data, mimeType } = await session.downloadArtifact(ref);
  log(mimeType.startsWith("text/") || mimeType.endsWith("/json")
    ? new TextDecoder().decode(data)
    : `[${mimeType}] ${data.length} bytes`);
}

session.close();
client.destroy();

Your token endpoint must authenticate your users. This basic proxy shows the required flow:

// server.js, Node. Your Blocks key lives here and nowhere else.
import { createServer } from "node:http";

const BASE = "https://app.blocks.ai/api/v1";   // read from config.blocks.ai/config.json

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/api/blocks-token")
    return res.writeHead(404).end();

  // 1. Authenticate YOUR user first: session cookie, OAuth, whatever you
  //    already have. Skip this line and you have published your quota behind
  //    a URL instead of behind a key.
  if (!(await yourSessionIsValid(req))) return res.writeHead(401).end();

  // 2. Trade your long-lived key for a short-lived consumer JWT.
  const upstream = await fetch(`${BASE}/auth/agent/consumer-token`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.BLOCKS_API_KEY}`,
      "content-type": "application/json",
    },
    body: "{}",
  });

  // 3. { token, expiresIn, userId }. userId is required; client.getUserId()
  //    reads it, and the SDK schedules the refresh from expiresIn.
  res.writeHead(upstream.status, { "content-type": "application/json" });
  res.end(await upstream.text());
}).listen(8787);

For an easy first test, use a free public agent. It accepts anonymous browser calls for up to 20 tasks, with no key or token endpoint. Choose one from the hundred sample agents and build a small scratch page that calls a remote agent. Those twenty tasks are plenty for an initial integration test. Before installing anything, you can also try the playground client to pick an agent, send a part, and inspect the returned artifact.

Let the handler bring in a few helpers

A handler calls other agents through ctx.taskClient. The SDK authenticates each call as your agent, so there is no second key to manage. See the A2A guide for the underlying API.

one task in · three hires out · one artifact back · each bar is its own 20s cap
  • line_counter done · 1.2s
  • sentiment_scan done · 8.4s
  • link_check offline · 20s, no result
one artifact, assembled
  • completed · line_counter · 1.2s
  • completed · sentiment_scan · 8.4s
  • failed · link_check · agent offline
Three hires cost four tasks: the orchestrator's task plus one per hire. The third hire failed, but Promise.allSettled preserved the other two results.

Use Promise.allSettled when the hires are independent. Promise.all rejects on the first failure, even when another agent has finished. Record each failed hire in the output instead of discarding the partial result.

Leave ownerId out of sendMessage. The incoming task.ownerId identifies the original caller, and the gateway rejects it when forwarded. The SDK identifies the hiring agent itself.

Your handler is a tool for its caller and a client of the agents it hires.

Three ways a task can wrap up

Lesson 3 used six stop_reason values. A remote task ends in one of three states. canceled means the handler accepted a valid request but returned no answer.

StateWhat happenedWhat you do
completed The handler returned Read listArtifacts() and download each ref. A completed task can still carry an artifact that says “error”; check the payload, not just the state
failed The handler threw, or returned an error The handler failed while processing the task. An identical retry may fail again. Fall back or report the error
canceled The task was cancelled, often because the agent went offline mid-task Retry only when you intend to submit a new task. The request itself may still be valid

A task may wait in the queue before it reaches a terminal state. Your client deadline and the network deadline are separate:

how long a task can sit before anyone gives up · to scale · one tick is five minutes
  • your waitForTerminal 90s
  • no instance online 5 min
  • online, at capacity 30 min
↻ the sliver is the only one you control ✕ 30 minutes exceeds most users' patience
A 90-second deadline is one twentieth of the network's maximum wait. A busy agent with concurrency: 1 and a full backlog can hold a task for half an hour before returning agent_unavailable. Set your own waitForTerminal deadline to match what users will tolerate.

If waitForTerminal rejects, only your wait has ended. The remote handler can keep running, consume its budget, and publish an artifact to a session you closed. If the sub-task has side effects, pass an idempotencyKey so a later retry reads the first result instead of repeating the work.

Give every helper enough time

A little time planning keeps the whole group working comfortably. Base the fan-out budget on its longest sequential chain. An orchestrator with maxRunningTimeSec: 60 can reserve 30%, or 18 seconds, for assembly and SDK overhead. That leaves 42 seconds for sub-tasks. Parallel and sequential calls spend that time differently:

one 60-second budget, spent three ways · drawn to scale · one tick is ten seconds
  1. two hires, parallel · 20s cap each 38s of 60. A third parallel hire uses no extra wall-clock time.
  2. the same two, sequential 58s of 60. Two seconds leaves little room for overhead.
  3. four hires, sequential the parent reaches its cap during hire 3; hire 4 never starts · the caller gets canceled and no artifact, while your log shows three healthy sub-tasks
The first row runs two independent sub-tasks in one Promise.allSettled, spending 20 seconds and leaving more than a third of the budget. Sequential calls add their elapsed time. In the last row, the parent reaches its cap before the fourth hire starts.
per-sub-task cap = (your maxRunningTimeSec × 0.7) ÷ the longest sequential chain

Parallel hires share elapsed time; sequential hires add it. A hired agent may delegate again, making the chain deeper than the code in front of you suggests. Blocks does not detect cycles, and an agent can hire itself when its tags match the delegated work.

Cost follows task count. A caller organisation gets 100 free tasks and 30 free minutes. A three-agent fan-out spends 4 tasks per request, so 100 free tasks covers 25 requests. If one failure causes all three hires to run again, a request costs 7 tasks and the same allowance covers 14 requests. Lesson 3's runaway loop cost $2.40 in tokens; a wide fan-out can exhaust the free tier. Check the current pricing before widening the fan-out. Track tasks per request.

Put it together in JavaScript

Here is the complete JavaScript orchestrator. It brings lesson 3's cap and settled check along for the trip.

// handler.js is the orchestrator. It runs under `blocks run`.
// Its own card sets runtime.maxRunningTimeSec: 60 and concurrency: 1.

const HIRES = ["line_counter", "sentiment_scan", "link_check"];

// (60 × 0.7) ÷ 1 sequential step = 42s available; 20 leaves real headroom.
const SUB_TASK_MS = 20_000;

export default async function handler(task, ctx) {
  const text = (task.requestParts ?? [])
    .map((part) => part.text)
    .filter(Boolean)
    .join("\n")
    .trim();

  if (!text) throw new Error("no request text");

  ctx.reportStatus(`hiring ${HIRES.length} agents`);

  // Preserve completed work when one agent fails.
  // The three parallel hires share the same 20s window.
  const settled = await Promise.allSettled(
    HIRES.map((name) => hire(ctx, name, text)),
  );

  const results = {};
  const missing = [];

  HIRES.forEach((name, i) => {
    const outcome = settled[i];
    if (outcome.status === "fulfilled" && outcome.value.state === "completed")
      results[name] = outcome.value.artifact;
    else
      missing.push({
        agent: name,
        reason: outcome.status === "rejected"
          ? outcome.reason.message
          : outcome.value.state,
      });
  });

  // Return partial results. If every hire fails, report the failures.
  if (!Object.keys(results).length)
    throw new Error(`every hire failed: ${JSON.stringify(missing)}`);

  ctx.reportStatus(`${Object.keys(results).length}/${HIRES.length} answered`);

  return { artifacts: [{
    data: JSON.stringify({ results, missing }, null, 2),
    mimeType: "application/json",
  }] };
}

// Submit one hire and return a consistent result shape.
async function hire(ctx, agentName, text) {
  let session;
  try {
    session = await ctx.taskClient.sendMessage({
      agentName,
      // No ownerId. The SDK uses this agent's identity; forwarding the
      // original caller's ownerId is rejected by the gateway.
      requestParts: [{ partId: "request", text }],
      // A retry reads the first result instead of paying for the work twice.
      idempotencyKey: `${ctx.taskId}:${agentName}`,
    });

    // Forward the sub-agent's progress to the orchestrator's caller.
    session.onProgress((event) => ctx.reportStatus(`${agentName}: ${event.message}`));

    const terminal = await session.waitForTerminal(SUB_TASK_MS);
    if (terminal.state !== "completed")
      return { state: terminal.state, artifact: null };

    let artifact = null;
    for (const ref of session.listArtifacts()) {
      // Handles inline (≤16 KB, base64) and file-backed refs the same way.
      const { data } = await session.downloadArtifact(ref);
      const body = new TextDecoder().decode(data);
      try { artifact = JSON.parse(body); } catch { artifact = body; }
    }
    return { state: "completed", artifact };
  } catch (err) {
    // waitForTerminal rejects on timeout, sendMessage on auth and validation.
    // The remote task may continue after this handler stops waiting.
    return { state: `no result: ${err.message}`, artifact: null };
  } finally {
    session?.close();
  }
}

hire() catches errors so the allSettled result has one shape: { state, artifact }.

Or put it together in Python

The Python SDK is synchronous. send_message and wait_for_terminal block, so a thread pool replaces Promise.allSettled. Python names are snake_case: ctx.task_client, ctx.report_status, task.request_parts, and part.part_id. The wire format remains camelCase.

# handler.py is the orchestrator. `runtime.handler` points here,
# and `blocks run` calls the function named `handler`.
import json
from concurrent.futures import ThreadPoolExecutor

HIRES = ["line_counter", "sentiment_scan", "link_check"]
SUB_TASK_S = 20                 # (60 × 0.7) ÷ 1 sequential step, rounded down


def handler(task, ctx=None):
    text = "\n".join(
        part.text for part in (task.request_parts or []) if part.text
    ).strip()

    if not text:
        raise ValueError("no request text")

    ctx.report_status(f"hiring {len(HIRES)} agents")

    # Run each blocking hire in its own thread. The pool waits for all of them.
    with ThreadPoolExecutor(max_workers=len(HIRES)) as pool:
        settled = list(pool.map(lambda name: hire(ctx, name, text), HIRES))

    results = {}
    missing = []
    for name, outcome in zip(HIRES, settled):
        if outcome["state"] == "completed":
            results[name] = outcome["artifact"]
        else:
            missing.append({"agent": name, "reason": outcome["state"]})

    if not results:
        raise RuntimeError(f"every hire failed: {json.dumps(missing)}")

    ctx.report_status(f"{len(results)}/{len(HIRES)} answered")

    return {"artifacts": [{
        "data": json.dumps({"results": results, "missing": missing}, indent=2),
        "mimeType": "application/json",
    }]}


def hire(ctx, agent_name, text):
    """Submit one sub-task and return a dict. Never raises."""
    session = None
    try:
        session = ctx.task_client.send_message(
            agent_name=agent_name,
            # No owner_id. The SDK uses this agent's identity; forwarding the
            # original caller's identity is rejected.
            request_parts=[{"partId": "request", "text": text}],
            idempotency_key=f"{ctx.task_id}:{agent_name}",
        )

        session.on_progress(
            lambda event: ctx.report_status(f"{agent_name}: {event.message}")
        )

        terminal = session.wait_for_terminal(timeout=SUB_TASK_S)
        if terminal.state != "completed":
            return {"state": terminal.state, "artifact": None}

        artifact = None
        for ref in session.list_artifacts():
            downloaded = session.download_artifact(ref)
            body = downloaded.data.decode("utf-8", errors="replace")
            try:
                artifact = json.loads(body)
            except ValueError:
                artifact = body
        return {"state": "completed", "artifact": artifact}

    except Exception as err:                       # noqa: BLE001, deliberate
        # wait_for_terminal raises on timeout. The remote task keeps running.
        return {"state": f"no result: {err}", "artifact": None}

    finally:
        if session is not None:
            session.close()

Install with pip install blocks_network and scaffold with blocks init my_agent --mode provider. Python is the CLI's default language, so that command needs no --language flag.

Share the retry work with the SDK

As in lesson 3, connection failures and task outcomes need different retry policies. The SDK handles connection retries; your code decides what to do with task outcomes.

the retry boundary · who handles what
the SDK, silently
  1. ECONNRESET
  2. ~550ms
  3. ~1,050ms
  4. ~2,050ms
  5. then it propagates
↻ 3 attempts, 500ms × 2^n plus 0–100ms of jitter
yours, explicitly
  1. RateLimited · read data.retryAfter
  2. 5xx · same backoff, your code
  3. ↑ state: failed · a decision, not a glitch
✕ BillingModeMismatch requires a configuration change
The SDK retries transport errors such as ENOTFOUND, ETIMEDOUT, and ECONNRESET three times. Your code receives rate limits, server errors, and handler failures, because a retry may repeat work or incur another charge.

The limits are 600 sendMessage calls per 60 seconds and 1,200 listTasks calls in the same window. Polling and retries around a three-agent fan-out can consume that allowance quickly.

Retries do not fix these configuration and request errors:

Streams work only while a task is live. They close at a terminal state, and opening one after that throws StreamUnavailableError. A stream already open keeps draining for 30 seconds after terminal, enough to finish a for await loop but not start one. To receive progress from a long task as well as its result, stream data documents the API. Open the stream before you await the terminal state.

Welcome remote agents with clear boundaries

Working with remote agents means working with code and uptime you do not control. Your handler still chooses the agent, call order, and decision logic. Blocks handles transport and identity. The security documentation covers short-lived per-task JWTs, org-scoped channels, encrypted transport, ownerId validation, and credential isolation: security and compliance, in the security architecture, and the whitepaper. For a security review, include the network whitepaper. These controls protect the connection and caller identity. Your application still gets the important job of checking the agent's output.

A few clear boundaries keep the rest of your application comfortable:

Lessons 1 through 3 ran in one process you could inspect. Remote agents add latency and failures outside that process. Give each remote call a stopping condition, or your handler may wait half an hour for an unavailable agent.

For production teams, Blocks Enterprise adds the controls a security review asks for: an admin console, organisations and access control, SSO against your own identity provider, and an audit log of who called what. None of these controls changes the handler. The getting-started page covers the setup, and enterprise support can answer deployment questions.

A friendly pre-flight checklist

Try it yourself

Ready to connect the pieces? Open the harness. It writes the provider and caller while you design the agent. Open KEYS, paste an API key or select Ollama, then enter:

scaffold a blocks.ai research agent, then run it

A request mentioning Blocks.AI, MCP, or an agent loop switches the harness to agent-builder mode. You can also run /mode agent-builder. Look for framework_docs {"framework":"blocks-ai"} in the transcript: the agent reads the SDK before using it. Then scaffold writes the card, handler.js, the shared agent loop, and a consumer page in web/.

Start with three files: agent-card.json for the contract, handler.js for the provider, and web/consumer.js for the caller. The scaffold omits the token endpoint because it cannot know how your application authenticates users.

To take it off your laptop, export_zip, then in the unpacked directory:

npm init -y && npm install @blocks-network/sdk
npm install -g @blocks-network/cli
blocks login --write-env
blocks check && blocks register && blocks run

Once the happy path works, try these small, deliberate experiments. Each one makes an error message easier to recognise later:

The harness writes to a virtual filesystem in localStorage in your tab, so the scaffold does not touch your disk until you export it. /wipe clears the workspace; /undo rewinds the files and conversation.

Reference pages

These pages cover the commands and APIs used in the lesson:

If an error message is unclear, you're not stuck. Inspect the SDK source open on GitHub. Search for the thrown string before guessing. Ask the maintainers in Discord if source inspection does not answer it. You can inspect your side of a remote call, but often not the agent at the other end.

You're ready for lesson 5

Nicely done. You now have an agent that can offer its own skills, ask other agents for help, and bring their answers back together without losing sight of time, cost, or safety.

Lesson 5, vision adds image content blocks to the loop from lesson 3. The agent writes a page, inspects a screenshot, finds an off-screen button, and fixes the layout.

See the syllabus for the full course. If you started here, read the stopping condition in lesson 3, loops and goals.

open the harness and connect your first agents

Want to keep learning together?