Skip to content

Build a Control Plane

Build with an agent

Paste the instruction into your coding harness, or follow the steps below.

Prompt draft
Download .txt
Read the instruction
Build the first chapter of Aster's MissionControl example in Python in the
current workspace.

Sources:
- https://sdk.getaster.now/docs/quickstart/mission-control
- https://github.com/aster-rpc/aster-rpc/tree/main/examples/python/mission_control
- https://sdk.getaster.now/docs/bindings/python

Read the walkthrough and inspect the public example source. Focus on the first
agent check-in and its unary status call. Use the request types, service name,
method name, and response shape defined there rather than making up a different
contract. Do not implement later chapters yet.

Use published package versions compatible with the source you select, and
record the Aster release, Python version, and example source revision. If they
do not agree, report the mismatch and stop instead of silently rewriting the
API or depending on a private repository.

Create an isolated environment, the necessary shared types and service, a
control-plane process, an agent process, and a README with exact commands.
Have the control plane print its actual Aster address, and configure the agent
to call that address. Use the tutorial's development configuration for a local
demonstration and document that it is not a production security setup. Do not
open public firewall ports or alter machine-wide networking settings.

Run both processes locally and verify the chapter's expected check-in/status
response over Aster. Document what was observed. Stop the processes you started
after verification unless I ask you to keep them running. Describe how to repeat
the demonstration across two machines, but do not claim that was tested unless
you actually have access to and use two machines.

Report the files created, versions and source revision, commands run, actual
response, and anything that prevented successful execution. Do not substitute
a mocked response for a working Aster call.

This prompt is a draft for the documentation design preview and has not yet been
validated in a coding harness.

This instruction has not yet been tested in a harness. It is included here to review the tutorial format.

You need two services to talk. So you set up a load balancer, provision TLS certs, write protobuf schemas, compile them, configure a service mesh, deploy to Kubernetes, and pray the health checks converge before the demo tomorrow.

Or: you write one file and run it.

@service(name="MissionControl", version=1)
class MissionControl:
@rpc()
async def getStatus(self, req: StatusRequest) -> StatusResponse:
return StatusResponse(agent_id=req.agent_id, status="running")
Terminal window
python control.py # that's the server
aster shell aster1Qm... # that's the client — tab completion, typed responses

No YAML. No protobuf compilation. No port numbers. No cloud account. Encrypted, authenticated, works across NATs, and your colleague on the other language can call it too.

What you’re replacing: Traditional RPC means writing .proto files, compiling them, setting up TLS certificates, configuring a reverse proxy or service mesh so clients can find your service, managing certificate rotation, and repeating all of that for every new service. With Aster you get mTLS-grade mutual authentication (no CA infrastructure), gRPC-style streaming RPCs (no .proto compilation), and peer-to-peer connectivity (no port forwarding or load balancers).

This guide builds Mission Control — a control plane for managing remote agents. An agent could be a CI runner, an IoT sensor, an AI worker, or a service on your colleague’s laptop across the world.

In under an hour you’ll have:

  • Agents that check in, push metrics, and stream logs
  • Operators that watch, issue commands, and control access
  • A cross-language agent talking to your control plane

Everything runs peer-to-peer. No infrastructure beyond a relay for NAT traversal (self-hostable). Once peers find each other, traffic flows direct.

MissionControl services and callersAgent 1, Agent 2, and the Operator CLI call the shared MissionControl and per-connection AgentSession services. Agents can use a relay when required for connectivity.Agent 1Agent 2Operator CLIMissionControl · sharedstatus · logs · metricsAgentSession · per connectionregister · heartbeat · commandsRelay fallback
Shared services and agent sessions use the same peer-to-peer transport.

Aster uses Iroh’s public relays for discovery and NAT traversal by default. Point to your own with a single environment variable: IROH_RELAY_URL=https://relay.yourcompany.com.


Two packages – the framework and the CLI:

Terminal window
uv pip install aster-rpc aster-cli
# or:
pip install aster-rpc aster-cli

The framework gives you from aster import ... for your service code. The CLI gives you aster shell, aster trust keygen, aster enroll node, and aster contract gen-client – the operator tools you’ll use throughout this guide.

Requirements: Python 3.9 – 3.13, macOS / Linux / Windows.

Verify:

Terminal window
aster --version

Chapter 1: Your First Agent Check-In (5 min)

Section titled “Chapter 1: Your First Agent Check-In (5 min)”

Goal: The full working version of what you just saw — define a service, start it, call it.

control.py
from dataclasses import dataclass
from aster import AsterServer, service, rpc, wire_type
@wire_type("mission/StatusRequest")
@dataclass
class StatusRequest:
agent_id: str = ""
@wire_type("mission/StatusResponse")
@dataclass
class StatusResponse:
agent_id: str = ""
status: str = "idle"
uptime_secs: int = 0
@service(name="MissionControl", version=1)
class MissionControl:
@rpc()
async def getStatus(self, req: StatusRequest) -> StatusResponse:
return StatusResponse(
agent_id=req.agent_id,
status="running",
uptime_secs=3600,
)
async def main():
async with AsterServer(services=[MissionControl()]) as srv:
print(srv.address) # compact aster1... address
await srv.serve()
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Terminal window
# Start the control plane
python control.py
# → aster1Qmxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ^^^^^^^ this is your control plane's public-key address — copy it for the next step

In another terminal, connect and inspect. Replace aster1Qm... below with the address your control.py just printed (the address is unique to each run; it’s an ephemeral key in dev mode):

Terminal window
aster shell aster1Qm...
> cd services/MissionControl
> ./getStatus agent_id="edge-node-7"

Or skip the shell entirely — call it straight from the command line. Replace aster1Qm... with your address:

Terminal window
# macOS / Linux
aster call aster1Qm... MissionControl.getStatus '{"agent_id": "edge-node-7"}'
Terminal window
# Windows (PowerShell). The --% operator tells PowerShell to stop parsing
# and pass the rest of the line verbatim — necessary because PowerShell's
# argument parser strips quotes from JSON arguments otherwise.
aster call aster1Qm... MissionControl.getStatus --% {"agent_id": "edge-node-7"}

aster shell vs aster call: Use aster shell for interactive exploration — browsing services, tab-completing methods, streaming. Use aster call for scripting and one-shot invocations. Both use JSON serialization under the hood.

What just happened:

  • Decorators defined a typed RPC contract
  • @wire_type / @WireType made the types serializable across languages — no .proto files, no separate schema to maintain
  • AsterServer created an encrypted QUIC endpoint and started listening — clients discover the service contract on connect
  • aster shell connected, discovered the service, and invoked it — with tab completion and typed responses

Goal: Agents push logs into the control plane. Operators tail them in real time using server streaming.

This chapter extends control.py (or control.ts) with two new methods: submitLog (a unary RPC) and tailLogs (a server-streaming RPC). To save you from puzzling over which lines go where, the full updated file is below — replace your existing control.py / control.ts with it.

Replace your control.py with this complete version:

control.py
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from aster import AsterServer, service, rpc, server_stream, wire_type
# ─────────────── Wire types ───────────────
@wire_type("mission/StatusRequest")
@dataclass
class StatusRequest:
agent_id: str = ""
@wire_type("mission/StatusResponse")
@dataclass
class StatusResponse:
agent_id: str = ""
status: str = "idle"
uptime_secs: int = 0
@wire_type("mission/LogEntry")
@dataclass
class LogEntry:
timestamp: float = 0.0
level: str = "info"
message: str = ""
agent_id: str = ""
@wire_type("mission/SubmitLogResult")
@dataclass
class SubmitLogResult:
accepted: bool = True
@wire_type("mission/TailRequest")
@dataclass
class TailRequest:
agent_id: str = ""
level: str = "info" # minimum level filter
# ─────────────── Helpers ───────────────
_LEVEL_ORDER = {"debug": 0, "info": 1, "warn": 2, "error": 3, "fatal": 4}
def _level_rank(level: str) -> int:
return _LEVEL_ORDER.get(level.lower(), 0)
# ─────────────── Service ───────────────
@service(name="MissionControl", version=1)
class MissionControl:
def __init__(self):
self._log_queue: asyncio.Queue[LogEntry] = asyncio.Queue()
@rpc()
async def getStatus(self, req: StatusRequest) -> StatusResponse:
return StatusResponse(
agent_id=req.agent_id,
status="running",
uptime_secs=3600,
)
@rpc()
async def submitLog(self, entry: LogEntry) -> SubmitLogResult:
"""Agents call this to push log entries."""
await self._log_queue.put(entry)
return SubmitLogResult(accepted=True)
@server_stream()
async def tailLogs(self, req: TailRequest) -> AsyncIterator[LogEntry]:
"""Stream log entries as they arrive."""
while True:
entry = await self._log_queue.get()
if req.agent_id and entry.agent_id != req.agent_id:
continue
if _level_rank(entry.level) < _level_rank(req.level):
continue
yield entry
# ─────────────── Main ───────────────
async def main():
async with AsterServer(services=[MissionControl()]) as srv:
print(srv.address) # compact aster1... address
await srv.serve()
if __name__ == "__main__":
asyncio.run(main())

Restart the service. Stop the previous run with Ctrl+C, then start the new version:

Terminal window
python control.py
# → aster1Qmxxxxxxxx... ← this is a NEW address; the old one is gone

The address changes on every restart in dev mode (it’s an ephemeral key). Copy the new address — you’ll need it for both terminals below.

tailLogs blocks until log entries arrive. For the demo we need a process that’s actively pushing logs, so the stream has something to show. Save this as logs.py (or logs.ts) and run it in a second terminal:

# logs.py — submits one log entry per second so tailLogs has something to stream.
# Usage: python logs.py <aster1...address>
import asyncio
import sys
import time
from aster import AsterClient
async def main():
if len(sys.argv) < 2:
print("Usage: python logs.py <aster1...address>")
sys.exit(1)
async with AsterClient(address=sys.argv[1]) as client:
mc = client.proxy("MissionControl")
levels = ["info", "warn", "error"]
messages = ["disk 92% full", "health check failed", "cpu spike detected"]
i = 0
while True:
await mc.submitLog({
"timestamp": time.time(),
"level": levels[i % len(levels)],
"message": messages[i % len(messages)],
"agent_id": "edge-node-7",
})
print(f"submitted log #{i + 1}")
i += 1
await asyncio.sleep(1)
if __name__ == "__main__":
asyncio.run(main())
Terminal window
# Replace aster1Qm... with the address from control.py
python logs.py aster1Qm...

In a third terminal, open the shell and start tailing. Replace aster1Qm... with the same address as before:

Terminal window
aster shell aster1Qm...
> cd services/MissionControl
> ./tailLogs agent_id="edge-node-7" level="warn"
#0 {"timestamp": 1712567890.1, "level": "warn", "message": "disk 92% full", ...}
#1 {"timestamp": 1712567891.3, "level": "error", "message": "health check failed", ...}
# Ctrl+C to stop

You should see entries scroll past in real time as logs.py submits them. Note the level="warn" filter excludes info entries, so you’ll see roughly two out of every three submitted logs.

Three terminals? Yes — server (control.py), submitter (logs.py), and tail consumer (aster shell). That’s the natural shape of any streaming demo: someone produces, someone consumes, the server brokers between them.

Or from your own code using the proxy client:

# Server-streaming methods are called via `.stream(...)` and iterated
# with `async for`. The plain `await mc.tailLogs({...})` form is for
# unary methods only — it will raise on a streaming RPC.
async for entry in mc.tailLogs.stream({"level": "warn"}):
print(entry)

When you want to allow another endpoint connect to yours, you must give it permission. You do that by generating a credential for it and putting in it the roles that endpoint should have.

Terminal window
# Edge agent — status and ingest only
aster enroll node --role consumer --name "edge-node-7" \
--capabilities ops.status,ops.ingest \
--root-key ~/.aster/root.key \
--out edge-node-7.cred

aster enroll node will print a summary like this:

✓ Enrollment credential created
File: /home/you/work/edge-node-7.cred
Format: TOML (.aster-identity) with [node] + [[peers]] sections
Peer: edge-node-7
Role: consumer (policy)
Capabilities: ops.status,ops.ingest
Endpoint ID: 142179f10b7bc606...
Trust root: cd948e4c1456cdbe...
Expires: 2026-05-10T20:20:12+00:00
This file lets a consumer connect to your trusted-mode servers.
It contains a node identity (secret key) AND a signed enrollment
credential. The server validates the credential and grants the
capabilities listed below.
Use it:
aster shell <peer-addr> --rcan edge-node-7.cred
aster call <peer-addr> Service.method '<json>' --rcan edge-node-7.cred
⚠ Keep this file secret -- it is both an identity AND a credential.
Terminal window
# Ops team — full access including admin
aster enroll node --role consumer --name "ops-team" \
--capabilities ops.status,ops.logs,ops.admin,ops.ingest \
--root-key ~/.aster/root.key \
--out ops-team.cred

Replace aster1Qm... in the snippets below with the address your control.py / control.ts printed in Step 3.

client = AsterClient(
address="aster1Qm...", # ← from control.py output
enrollment_credential_file="edge-node-7.cred",
)
await client.connect()
mc = client.proxy("MissionControl")
await mc.getStatus({"agent_id": "test"}) # ✓ has ops.status
await mc.ingestMetrics(...) # ✓ has ops.ingest
# await agent.runCommand(...) # ✗ AccessDenied — missing ops.admin
Terminal window
# Or from the CLI — the shell respects credentials too.
# Replace aster1Qm... with the address from control.py.
aster shell aster1Qm... --rcan ops-team.cred
> cd services
> session AgentSession # opens session subshell
AgentSession~ runCommand command="df" # ✓ ops-team has ops.admin

What just happened:

  • aster trust keygen created the root of trust — one command
  • aster enroll node issued scoped credentials — no CA infrastructure
  • requires= — Aster checks at the method level, no auth middleware to write
  • any_of(A, B) — caller needs at least one (log viewers OR admins can tail)
  • The edge agent can push metrics but can’t run commands. The ops team can do both. That’s the entire access control model — defined in code, enforced at the wire level

Goal: Your teammate uses a different language. They don’t have your source code — just the server address.

Your control.py / control.ts from Chapter 4 (or 5 with auth) keeps running. The cross-language client below is a brand-new file in a different language — no shared source, no codegen, no .proto file.

A TypeScript teammate calls your Python control plane. Save this as ts-agent.ts (anywhere — it doesn’t need to live in the same directory as your Python code) and run it in a separate terminal. Replace aster1Qm... with the address your control.py printed.

// ts-agent.ts — TypeScript client calling a Python control plane.
// Usage: bun run ts-agent.ts <aster1...address>
import { AsterClientWrapper } from '@aster-rpc/aster';
async function main() {
const address = process.argv[2];
if (!address) {
console.error("Usage: bun run ts-agent.ts <aster1...address>");
process.exit(1);
}
const client = new AsterClientWrapper({ address });
await client.connect();
const mc = client.proxy("MissionControl");
const status = await mc.getStatus({ agent_id: "ts-worker-1" });
console.log(`Status: ${status.agent_id} is ${status.status}`);
// Stream metrics from TypeScript to the Python control plane
const result = await mc.ingestMetrics(async function*() {
for (let i = 0; i < 1000; i++) {
yield { name: "gpu.temp", value: 72 + Math.random() * 10 };
}
}());
console.log(`Accepted: ${result.accepted}`);
await client.close();
}
main();
Terminal window
bun run ts-agent.ts aster1Qm...

What just happened:

  • Your teammate never saw your source code
  • The proxy client discovered the contract on connect and built method stubs dynamically — full RPC, no codegen required
  • Same wire format, same contract hash — producer and consumer agree on the protocol without sharing a repo

“But there’s no .proto file — how does the other language know what you sent?” — The @wire_type / @WireType decorator registers each type’s schema in Aster’s content-addressed contract. The contract is published with the service and discovered on connect. The contract is the shared schema — you just never had to write it by hand.


You just built a working control plane with four RPC patterns, session-scoped agents, capability-based auth, and cross-language interop. That’s a real system — not a demo.

Next guides in the series:

  • Hardening for Production — interceptors for retry, circuit-breaking, rate limiting, and deadlines
  • Scaling Out — multiple producers with automatic fail-over
  • Artifact Distribution — push builds and model weights to agents with content-addressed blobs
  • Shared Fleet State — CRDT documents that sync across your fleet

The full source for this example is in examples/python/mission_control/ and examples/typescript/missionControl/.