Build a personal agent network
Connect a coordinator, a private knowledge node, and a compute node with independent identities, explicit membership, and governed remote tools.
On this page
A personal agent operating system can span machines and services you own, each keeping its own keys. This application starts a coordinator, a knowledge node, and a compute node as separate processes with their own directories, kernels, and state; the coordinator retrieves a private passage, requests bounded statistics, and delivers a signed observation over iroh.
Run the networkThree node processes cooperate without sharing keys
An assistant with independently governed nodes
A coordinator queries private knowledge, calls a bounded compute service, and delivers signed observations over Iroh.
Change the private document, the question, or the numeric samples, and inspect each node’s identity and the remote receipts it returns. With lifecycle checks on, the application removes membership, tests new and existing connections, restores the signed directory, stops the compute process, and restarts it with its retained identity. The output separates the failed offline request from the new task after restart.
Membership comes from the directory and tool authority from each node
The signed directory says who belongs. Each node then grants its own tools, admits remote calls under its own kernel, and signs the receipts the coordinator verifies.
Each node grants its own tools
Installing a signed directory establishes membership; tool authority is a separate decision of the receiving node, bound to the coordinator's identity. Removing a peer is checked for new connections and for messages on held connections, and expired or rolled-back directory versions are refused. Let each node grant its own tools.
Each node grants its own tools
Installing a signed peer directory establishes membership, and tool authority is a separate decision of the receiving node. The grant endpoint binds a scoped capability to the coordinator’s identity. The private signing keys of the knowledge and compute hosts stay on those hosts.
async fn issue(
State(node): State<Arc<Node>>,
headers: HeaderMap,
Json(input): Json<Value>,
) -> std::result::Result<Json<Value>, (StatusCode, String)> {
admin(&node, &headers).map_err(error)?;
node.directory.live().map_err(error)?;
let directory = node.directory.gate.directory();
let peer = input["peer"]
.as_str()
.ok_or_else(|| error("Choose a peer"))?;
let public = directory
.resolve_passport_key(peer)
.ok_or_else(|| error("Peer is not admitted"))?;
let tool = if node.config.role == "knowledge" {
"retrieve"
} else {
"statistics"
};
let cap = node
.host
.kernel
.issue_capability(
public,
ChioScope {
grants: vec![grant("personal", tool, 8)],
..Default::default()
},
300,
)
.map_err(error)?;
Ok(Json(json!({"capability":cap})))
}The effect owner admits each remote call
The receiving host validates the passport-signed request, the selected directory, the subject binding, and freshness before it submits the tool request to its kernel, with the handler inside that boundary. The caller verifies the returned receipt against the remote kernel key it selected independently.
async fn invoke(
State(node): State<Arc<Node>>,
Json(call): Json<SignedRequest>,
) -> std::result::Result<Json<Value>, (StatusCode, String)> {
node.directory.live().map_err(error)?;
let directory = node.directory.gate.directory();
let sender = directory
.resolve_passport_key(&call.sender)
.ok_or_else(|| {
(
StatusCode::FORBIDDEN,
"Caller is not in this node's current directory".into(),
)
})?;
if now_ms().abs_diff(call.issued_at_ms) > 30_000
|| *sender != call.request.capability.subject
|| !sender.verify_strict(
&request_preimage(&call.sender, call.issued_at_ms, &call.request).map_err(error)?,
&call.signature,
)
{
return Err((
StatusCode::UNAUTHORIZED,
"The caller signature, freshness, or capability subject is invalid".into(),
));
}
let result = node
.host
.call_controlled(
&node.run,
&call.sender,
call.request,
None,
Arc::new(AtomicBool::new(false)),
)
.await
.map_err(error)?;
let snapshot = node.run.snapshot().map_err(error)?;
let receipt = snapshot["events"]
.as_array()
.and_then(|events| {
events
.iter()
.rev()
.find(|event| event["data"]["receipt_id"] == result.receipt_id)
})
.map(|event| event["data"]["receipt"].clone())
.unwrap_or(Value::Null);
Ok(Json(json!({
"allowed":result.allowed,
"output":result.output,
"receipt_id":result.receipt_id,
"receipt":receipt,
"trusted_kernel":node.host.signer,
"pid":std::process::id()
})))
}In the verified run, events 07 and 14 record the allowed retrieve and statistics calls, with receipts 2b0f4eb5…e68e50 and 2c2dd245…8d08ad.
Observations travel the iroh lane and tools travel signed HTTP
The iroh directed-batch lane sends signed observations over QUIC, and the receiver applies its signature, treaty, replay, and scarcity checks. Tool dispatch uses the passport-authenticated HTTP interface. Each connection’s protocol label shows which capability it carries.
async fn wire(
State(node): State<Arc<Node>>,
headers: HeaderMap,
Json(input): Json<Value>,
) -> std::result::Result<Json<Value>, (StatusCode, String)> {
admin(&node, &headers).map_err(error)?;
let peer: Enrollment = serde_json::from_value(input["peer"].clone()).map_err(error)?;
let batch = crate::wire::batch(
&node.passport,
&node.local,
&peer.entry.kernel_id,
input["observation"].clone(),
)
.map_err(error)?;
let result = tokio::time::timeout(
std::time::Duration::from_secs(15),
deliver_batch_over_iroh(&node.endpoint, peer.address, &batch),
)
.await
.map_err(error)?
.map_err(error)?;
Ok(Json(json!({
"accepted":result.accepted,
"report":serde_json::from_slice::<Value>(&result.report_json).map_err(error)?,
"transport":"Iroh directed pheromone batch"
})))
}Events 08 and 11 record signed observations received over iroh.
A node installs only a valid directory successor
The node installs only a valid successor to its selected directory. Peer removal is checked at admission for new connections and for messages on held connections, and expired or rolled-back versions are refused. A restored peer needs an admitted successor before it reconnects.
async fn update(
State(node): State<Arc<Node>>,
headers: HeaderMap,
Json(bundle): Json<TransportDirectoryBundleDocument>,
) -> std::result::Result<Json<Value>, (StatusCode, String)> {
admin(&node, &headers).map_err(error)?;
let version = node.directory.update(bundle).map_err(error)?;
node.run
.emit(
"directory.updated",
&node.local,
"Installed a signed directory successor",
json!({"version":version}),
)
.map_err(error)?;
Ok(Json(
json!({"version":version,"body_sha256":node.directory.gate.current_body_sha256()}),
))
}Events 02, 05, 09, 10, and 12 each install a signed directory successor; events 09 and 10 move one node to versions 2 and 3.
Unknown peers, removed members, and stale directories are refused
The lifecycle checks in the sample input remove and restore membership. The retained run holds the directory changes and the observation sent after reconnection:
./run --run input.json09 directory.updated did:chio:876a81d35…
Installed a signed directory successor
10 directory.updated did:chio:876a81d35…
Installed a signed directory successor
11 network.observation did:chio:876a81d35…
Received a signed observation over Iroh
12 directory.updated did:chio:ee15972b0…
Installed a signed directory successor- Connect an unknown peer: it reaches no protected handler.
- Remove the coordinator and send on a held connection: the receiver refuses the message.
- Install an expired or rolled-back directory: the node keeps its current membership.
- Stop compute: the task stays unsuccessful, and after restart a new request uses the retained identity and original grant.
The downloadable tests assert each effect or its absence: run cargo test --locked -p chio-personal-network from the extracted project.
Run the network on your machine
Keep the run directory to inspect node identities, membership changes, and the original remote receipts.
cd chio-personal-network
./runThe archive holds the application, the shared host code, tests, sample input, pinned dependencies, and a README. The launcher builds and runs the Rust source included in this archive. Install Rust 1.94.1 and the README’s native prerequisites; filesystem isolation and operator workloads require the listed Linux host profile. Cargo resolves immutable public dependencies, so a sibling checkout is unnecessary.
For model workers, set OPENROUTER_API_KEY on the local host and select model mode; credentials stay on that host and never enter the docs form. The qualified model is openai/gpt-4.1-mini. Analysis, retrieval, and deterministic worker modes run without a model key. Ctrl-C stops the local web process and its active child work and keeps the run directory.
Download identity and source revision
Download complete project · View public source
8b01080993c3c40e919747dde74e8d428dc8cf748f275955295be0c8cafe966cMove a node to a second machine
Use the included enrollment utility and two-machine guide to move a node to another Linux host. Configure HTTPS for governed tool dispatch and iroh relay connectivity for observations; the qualified remote profile checks both paths. Network reachability alone grants no membership or authority.