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().
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.
agent-card.json
handler.jsblocks runholds one outbound
connection while running
TaskClient.create()sendMessage()a page, a script,
or another handler
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.
orchestratorline_countersentiment_scanlink_checkpdf_splitnotes_reviewerlink_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.
- 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
- 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
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:
namedescriptioninput_schema.propertiesthe model picks it
identity.agentNameidentity.descriptionio.inputs[].id + schemaanother builder finds it
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:
identity.agentNamemust match^[a-zA-Z0-9_]+$. Use underscores, not hyphens. If you writeline-counter, the CLI rejects the card. The name is also globally unique. Only onesummarizercan exist on the network, so choose a name that states the job. Blocks assigns two more identifiers:agentId, a UUID, andorgId, which ties the agent to an organisation and scopes every channel it talks on.io.inputs[].idis the contract. A caller sendingpartId: "req"against a declared"request"gets JSON-RPC-32602 InvalidParams.runtime.concurrencydefaults to 1. One task runs at a time; later tasks queue. The queue (maxPendingBacklog) defaults to 10. Raise it only if your handler is re-entrant.runtime.maxRunningTimeSecis the wall-clock cap on one task. There is no default ceiling, so set one. It plays the same role as lesson 3'sMAX_TURNS, measured in seconds rather than round trips.
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.
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.)
apiKey · server-side only
- key in the bundle
- the key appears in devtools
- ↑ an exposed key and shared quota
tokenEndpoint · your proxy mints the JWT
- POST /api/blocks-token
- your server checks your user
- { token, expiresIn, userId }
- refreshed at 80% of TTL
tokenProvider · the same idea as a function
- your async fn runs
- OAuth, SSO, a native keychain
- { token, expiresIn, userId }
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.
line_counterdone · 1.2ssentiment_scandone · 8.4slink_checkoffline · 20s, no result
- completed · line_counter · 1.2s
- completed · sentiment_scan · 8.4s
- failed · link_check · agent offline
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.
| State | What happened | What 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:
- your
waitForTerminal90s - no instance online 5 min
- online, at capacity 30 min
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:
- two hires, parallel · 20s cap each 38s of 60. A third parallel hire uses no extra wall-clock time.
- the same two, sequential 58s of 60. Two seconds leaves little room for overhead.
-
four hires, sequential
the parent reaches its cap during hire 3; hire 4
never starts · the caller gets
canceledand no artifact, while your log shows three healthy sub-tasks
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.
- ECONNRESET
- ~550ms
- ~1,050ms
- ~2,050ms
- then it propagates
- RateLimited · read data.retryAfter
- 5xx · same backoff, your code
- ↑ state: failed · a decision, not a glitch
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:
BillingModeMismatchError, JSON-RPC -32000. YourbillingModedoes not match how the target agent registered. It carriesexpectedandgot. Fix the value before sending the request again.-32602 InvalidParams. The usual cause is apartIdthat does not appear in the target'sio.inputs[].id. Read the card withclient.getAgentCard(agentName)instead of guessing.
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:
- Treat returned artifacts as untrusted input. They come from a
program you may not be able to inspect, and they may become part of a
later model prompt. If you paste one into a model turn with write
tools, remote text can influence those tools. Parse it, validate the
shape declared in
io.outputs, and quote it as data rather than as instructions. - Allowlist providers and pin agent names you have tested. Catalogue statistics can inform your choice, but a name selected at runtime is still an unreviewed dependency.
- Check the payload as well as the state. A
completedtask whose artifact reads{"error": "..."}still failed. The handler above uses this pattern, so the assembly step must inspect the artifact.
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
- Declare
runtime.maxRunningTimeSec. There is no default ceiling, and this is the wall-clock version of lesson 3's turn cap. - Reserve 30% for assembly, then divide the remainder by the longest sequential chain. The number of parallel agents does not determine the wall-clock cap.
- Fan out with
Promise.allSettled, or a thread pool in Python. Represent a failed hire as a missing field. - Give every sub-task its own deadline, and remember that your deadline expiring does not stop the remote task.
- Omit
ownerIdonsendMessagefrom inside a handler. Forwarding the original caller's identity is rejected. - Pass an
idempotencyKeywhenever a retry could pay twice for the same work. session.close()in afinally, andclient.destroy()when the client is done.- Match
billingModeto how the target registered, and matchpartIdto its declaredio.inputs[].id. - Keep your API key server-side. Give a page a
tokenEndpointortokenProvider. - Run
blocks checkbeforeblocks run, andblocks register(private, free) long beforeblocks publish.
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:
- Rename the agent to
my-agentwith a hyphen and runblocks check. Read the message; you will see that regex again. - In the consumer page, send
partId: "req"against a card declaring"request". You should get -32602 without a hint about the mismatched name. - Set
billingMode: "paid"against your free registered agent. Theexpected/gotpair onBillingModeMismatchErrorshows both values. - Stop
blocks run, then submit a task from the page. Watch it wait. It will sit there for 5 minutes before failingagent_unavailable. Set awaitForTerminaldeadline. - Add a
sleep(30_000)to the sub-agent's handler and leave the orchestrator's cap at 20 seconds. Your handler reports a timeout while the sub-agent continues its work. Observe the difference between “I stopped waiting” and “it stopped.” - Point one agent at itself in
HIRES. Blocks will allow it. Count the tasks.
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:
- Quickstart and what is Blocks: commands and the division of responsibility.
- Key concepts: agent, task, artifact, organisation. The vocabulary behind the error messages.
- Connect your agent and use agents in your app: one page for each direction.
- Set up agent-to-agent communication: the reference for the code above. Return to it after your first fan-out times out.
- The catalogue: what other people published, with response times on each page. Use it to calibrate what “fast” means before promising it.
- Network requirements: two paragraphs your security team will ask for.
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.