Build a Control Plane
Build with an agent
Paste the instruction into your coding harness, or follow the steps below.
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")python control.py # that's the serveraster shell aster1Qm... # that's the client — tab completion, typed responses@Service({ name: "MissionControl", version: 1 })class MissionControl { @Rpc() async getStatus(req: StatusRequest): Promise<StatusResponse> { return new StatusResponse({ agent_id: req.agent_id, status: "running" }); }}npx aster-gen # generate type metadata (one-time build step)node control.ts # that's the serveraster shell aster1Qm... # that's the client — tab completion, typed responsesNo 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.
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.
Install
Section titled “Install”Two packages – the framework and the CLI:
uv pip install aster-rpc aster-cli# or:pip install aster-rpc aster-cliThe 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.
Two pieces – the TypeScript runtime and the CLI tools:
# In your TypeScript project:bun add @aster-rpc/aster# or: npm install @aster-rpc/aster
# The CLI ships as a Python package and is shared across all language# bindings -- one shell, one trust manager, one contract generator,# usable against any Aster server regardless of language:uv tool install aster-cli# or: pip install aster-cliRequirements: Node.js 20+ or Bun 1.0+ for the TypeScript runtime. Python 3.9 – 3.13 for the CLI (one-time install; day-to-day work stays in TypeScript).
Verify:
aster --versionChapter 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.
from dataclasses import dataclassfrom aster import AsterServer, service, rpc, wire_type
@wire_type("mission/StatusRequest")@dataclassclass StatusRequest: agent_id: str = ""
@wire_type("mission/StatusResponse")@dataclassclass 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())# Start the control planepython control.py# → aster1Qmxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx# ^^^^^^^ this is your control plane's public-key address — copy it for the next stepIn 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):
aster shell aster1Qm...> cd services/MissionControl> ./getStatus agent_id="edge-node-7"import { AsterServer, Service, Rpc, WireType,} from '@aster-rpc/aster';
@WireType("mission/StatusRequest")class StatusRequest { agent_id: string = ""; constructor(init?: Partial<StatusRequest>) { if (init) Object.assign(this, init); }}
@WireType("mission/StatusResponse")class StatusResponse { agent_id: string = ""; status: string = "idle"; uptime_secs: number = 0; constructor(init?: Partial<StatusResponse>) { if (init) Object.assign(this, init); }}
@Service({ name: "MissionControl", version: 1 })class MissionControl { @Rpc() async getStatus(req: StatusRequest): Promise<StatusResponse> { return new StatusResponse({ agent_id: req.agent_id, status: "running", uptime_secs: 3600, }); }}
async function main() { const server = new AsterServer({ services: [new MissionControl()] }); await server.start(); console.log(server.address); // compact aster1... address await server.serve();}
main();# Generate type metadata (run once, re-run after changing types/methods)npx aster-gen
# Start the control planenode control.ts# → aster1Qmxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx# ^^^^^^^ this is your control plane's public-key address — copy it for the next stepIn another terminal, connect and inspect. Replace aster1Qm... below with the address your control.ts just printed (the address is unique to each run; it’s an ephemeral key in dev mode):
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:
# macOS / Linuxaster call aster1Qm... MissionControl.getStatus '{"agent_id": "edge-node-7"}'# 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 shellvsaster call: Useaster shellfor interactive exploration — browsing services, tab-completing methods, streaming. Useaster callfor scripting and one-shot invocations. Both use JSON serialization under the hood.
What just happened:
- Decorators defined a typed RPC contract
@wire_type/@WireTypemade the types serializable across languages — no.protofiles, no separate schema to maintainAsterServercreated an encrypted QUIC endpoint and started listening — clients discover the service contract on connectaster shellconnected, discovered the service, and invoked it — with tab completion and typed responses
Chapter 2: Live Log Streaming (5 min)
Section titled “Chapter 2: Live Log Streaming (5 min)”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:
import asynciofrom collections.abc import AsyncIteratorfrom dataclasses import dataclassfrom aster import AsterServer, service, rpc, server_stream, wire_type
# ─────────────── Wire types ───────────────
@wire_type("mission/StatusRequest")@dataclassclass StatusRequest: agent_id: str = ""
@wire_type("mission/StatusResponse")@dataclassclass StatusResponse: agent_id: str = "" status: str = "idle" uptime_secs: int = 0
@wire_type("mission/LogEntry")@dataclassclass LogEntry: timestamp: float = 0.0 level: str = "info" message: str = "" agent_id: str = ""
@wire_type("mission/SubmitLogResult")@dataclassclass SubmitLogResult: accepted: bool = True
@wire_type("mission/TailRequest")@dataclassclass 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:
python control.py# → aster1Qmxxxxxxxx... ← this is a NEW address; the old one is goneThe 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.
Replace your control.ts with this complete version:
import { AsterServer, Service, Rpc, ServerStream, WireType,} from '@aster-rpc/aster';
// ─────────────── Wire types ───────────────
@WireType("mission/StatusRequest")class StatusRequest { agent_id: string = ""; constructor(init?: Partial<StatusRequest>) { if (init) Object.assign(this, init); }}
@WireType("mission/StatusResponse")class StatusResponse { agent_id: string = ""; status: string = "idle"; uptime_secs: number = 0; constructor(init?: Partial<StatusResponse>) { if (init) Object.assign(this, init); }}
@WireType("mission/LogEntry")class LogEntry { timestamp: number = 0.0; level: string = "info"; message: string = ""; agent_id: string = ""; constructor(init?: Partial<LogEntry>) { if (init) Object.assign(this, init); }}
@WireType("mission/SubmitLogResult")class SubmitLogResult { accepted: boolean = true; constructor(init?: Partial<SubmitLogResult>) { if (init) Object.assign(this, init); }}
@WireType("mission/TailRequest")class TailRequest { agent_id: string = ""; level: string = "info"; // minimum level filter constructor(init?: Partial<TailRequest>) { if (init) Object.assign(this, init); }}
// ─────────────── Helpers ───────────────
const LEVEL_ORDER: Record<string, number> = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4,};function levelRank(level: string): number { return LEVEL_ORDER[level.toLowerCase()] ?? 0;}
// ─────────────── Service ───────────────
@Service({ name: "MissionControl", version: 1 })class MissionControl { private _logBuffer: LogEntry[] = []; private _logResolve: ((entry: LogEntry) => void) | null = null;
@Rpc() async getStatus(req: StatusRequest): Promise<StatusResponse> { return new StatusResponse({ agent_id: req.agent_id, status: "running", uptime_secs: 3600, }); }
@Rpc() async submitLog(entry: LogEntry): Promise<SubmitLogResult> { if (this._logResolve) { this._logResolve(entry); this._logResolve = null; } else { this._logBuffer.push(entry); } return new SubmitLogResult(); }
@ServerStream() async *tailLogs(req: TailRequest): AsyncGenerator<LogEntry> { while (true) { const entry = this._logBuffer.length > 0 ? this._logBuffer.shift()! : await new Promise<LogEntry>(resolve => { this._logResolve = resolve; }); if (req.agent_id && entry.agent_id !== req.agent_id) continue; if (levelRank(entry.level) < levelRank(req.level)) continue; yield entry; } }}
// ─────────────── Main ───────────────
async function main() { const server = new AsterServer({ services: [new MissionControl()] }); await server.start(); console.log(server.address); // compact aster1... address await server.serve();}
main();Restart the service. Stop the previous run with Ctrl+C, re-run the scanner and start the new version:
npx aster-gennode control.ts# → aster1Qmxxxxxxxx... ← this is a NEW address; the old one is goneThe 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.
Submit some logs to stream
Section titled “Submit some logs to stream”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 asyncioimport sysimport timefrom 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())# Replace aster1Qm... with the address from control.pypython logs.py aster1Qm...// logs.ts — submits one log entry per second so tailLogs has something to stream.// Usage: bun run logs.ts <aster1...address>import { AsterClientWrapper } from '@aster-rpc/aster';
async function main() { const address = process.argv[2]; if (!address) { console.error("Usage: bun run logs.ts <aster1...address>"); process.exit(1); }
const client = new AsterClientWrapper({ address }); await client.connect(); const mc = client.proxy("MissionControl");
const levels = ["info", "warn", "error"]; const messages = ["disk 92% full", "health check failed", "cpu spike detected"];
let i = 0; while (true) { await mc.submitLog({ timestamp: Date.now() / 1000, level: levels[i % levels.length], message: messages[i % messages.length], agent_id: "edge-node-7", }); console.log(`submitted log #${i + 1}`); i++; await new Promise(resolve => setTimeout(resolve, 1000)); }}
main();# Replace aster1Qm... with the address from control.tsbun run logs.ts aster1Qm...Tail the stream
Section titled “Tail the stream”In a third terminal, open the shell and start tailing. Replace aster1Qm... with the same address as before:
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 stopYou 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)// Server-streaming methods are called via `.stream(...)` and iterated// with `for await`. Calling `await mc.tailLogs({...})` directly is for// unary methods only — it will throw on a streaming RPC.for await (const entry of mc.tailLogs.stream({ level: "warn" })) { console.log(entry);}Step 4: Enroll agents
Section titled “Step 4: Enroll agents”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.
# Edge agent — status and ingest onlyaster enroll node --role consumer --name "edge-node-7" \ --capabilities ops.status,ops.ingest \ --root-key ~/.aster/root.key \ --out edge-node-7.credaster 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.Despite the .cred extension, it’s a regular .aster-identity TOML
file with two sections:
[node]— the consumer’s secret key + endpoint ID. Used by the QUIC layer to prove the consumer’s identity.[[peers]]— the signed enrollment credential. Presented to servers to claim capabilities.
Both sections live in the same file because they’re paired: the
server checks that the QUIC peer ID matches the credential’s
endpoint_id. If they don’t match, admission fails.
# Ops team — full access including adminaster enroll node --role consumer --name "ops-team" \ --capabilities ops.status,ops.logs,ops.admin,ops.ingest \ --root-key ~/.aster/root.key \ --out ops-team.credPass --quiet (or -q) to suppress the educational output. The
command prints exactly one line: <path> <endpoint_id> <expires_iso>
on success and exits non-zero on failure. Easy to parse from CI.
Step 5: Connect with credentials
Section titled “Step 5: Connect with credentials”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.statusawait mc.ingestMetrics(...) # ✓ has ops.ingest# await agent.runCommand(...) # ✗ AccessDenied — missing ops.adminconst client = new AsterClientWrapper({ address: "aster1Qm...", // ← from control.ts output enrollmentCredentialFile: "edge-node-7.cred",});await client.connect();const mc = client.proxy("MissionControl");
await mc.getStatus({ agent_id: "test" }); // ✓ has ops.statusawait mc.ingestMetrics(...); // ✓ has ops.ingest// await agent.runCommand(...); // ✗ AccessDenied — missing ops.admin# 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 subshellAgentSession~ runCommand command="df" # ✓ ops-team has ops.adminWhat just happened:
aster trust keygencreated the root of trust — one commandaster enroll nodeissued scoped credentials — no CA infrastructurerequires=— Aster checks at the method level, no auth middleware to writeany_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
Chapter 6: Cross-Language Interop (5 min)
Section titled “Chapter 6: Cross-Language Interop (5 min)”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();bun run ts-agent.ts aster1Qm...A Python teammate calls your TypeScript control plane. Save this as py-agent.py (anywhere) and run it in a separate terminal. Replace aster1Qm... with the address your control.ts printed.
# py-agent.py — Python client calling a TypeScript control plane.# Usage: python py-agent.py <aster1...address>import asyncioimport randomimport sysfrom aster import AsterClient
async def main(): if len(sys.argv) < 2: print("Usage: python py-agent.py <aster1...address>") sys.exit(1)
async with AsterClient(address=sys.argv[1]) as client: mc = client.proxy("MissionControl") status = await mc.getStatus({"agent_id": "py-worker-1"}) print(f"Status: {status['agent_id']} is {status['status']}")
# Stream metrics from Python to the TypeScript control plane async def metrics(): for i in range(1000): yield {"name": "gpu.temp", "value": 72 + random.random() * 10}
result = await mc.ingestMetrics(metrics()) print(f"Accepted: {result['accepted']}")
if __name__ == "__main__": asyncio.run(main())python py-agent.py 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/@WireTypedecorator 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.
What’s Next?
Section titled “What’s Next?”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/.