Consuming attacca_api
Call Attacca back from inside a node — list agents, open sessions, send messages and stream turns.
Every capability so far is something a node offers. attacca_api runs the other way: Attacca
announces it to the node right after the handshake, and the node calls it — over the same
websocket it serves its own tools on.
Attacca reserves the name. A node that tries to announce attacca_api has it rejected with
capability_rejected, reason reserved.
Setup
The declaration lives in its own crate, zyris-attacca. It is not part of zyris because Attacca
is one deployment, not the protocol.
[dependencies]
zyris = { git = "https://github.com/attacca-cc/zyris-protocol", features = ["tls-ring"] }
zyris-attacca = { git = "https://github.com/attacca-cc/zyris-protocol" }
futures = "0.3"The credential needs the scopes for what you call — for the example below,
agents:read, sessions:write and events:read. The last is required for turn_events, even when
the credential can create and message sessions.
Example: ask an agent something
use std::time::Duration;
use futures::StreamExt;
use zyris::{Connection, Node, NodeKind};
use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZTurnFrame};
async fn ask(conn: Connection) -> zyris::Result<()> {
let api: AttaccaApiClient = conn.wait_capability(Duration::from_secs(10)).await?;
let me = api.me().await?;
println!("connected as {} with scopes {:?}", me.email, me.scopes);
let agent = api.list_agents().await?.into_iter().next()
.ok_or_else(|| zyris::Error::invalid_params("this account has no agents"))?;
let session = api.create_session(agent.id, None, None).await?;
// Subscribe before sending, so no frame of the turn is missed.
let mut turn = api.turn_events(session.id.clone(), None).await?;
api.send_message(session.id, "What is on this machine's desktop?".into(), vec![]).await?;
while let Some(frame) = turn.items.next().await {
let frame = frame?;
let done = matches!(&frame, ZTurnFrame::Status { running: false });
println!("{:?}", frame);
if done {
break;
}
}
Ok(())
}
let node = Node::builder()
.name("driver")
.kind(NodeKind::Service)
.on_connect(|conn| async move {
if let Err(error) = ask(conn).await {
eprintln!("attacca_api: {error}");
}
})
.build()?;on_connect runs on every established connection, including after a reconnect, so a node that
drives a long session should resume it rather than start a new one — turn_events takes an after
cursor for exactly that. Persist the session ID and durable event cursor; reconnecting should not
create another session or send the original message again. The example above creates a fresh
session per connection for demonstration, not as a production resume policy.
The event subscription can remain open after a turn completes. The one-shot example stops at
Status { running: false } instead of waiting indefinitely for the stream to close.
Scopes are checked per call
Every tool except me is scope-checked when it is called. The announcement lists every tool
regardless of the grant, so a tool being announced is not permission to call it. Call me and read
scopes to find out what this credential may do; a call outside the grant fails with
forbidden_scope.
The tools
| Area | Tools |
|---|---|
| Identity | me |
| Agents | list_agents, create_agent |
| Projects | list_projects, get_project, create_project, update_project, delete_project |
| Sessions | list_sessions, create_session, create_session_with, session_history, session_usage |
| Turns | send_message, cancel_turn, turn_events (streaming) |
| Jobs | list_jobs, get_job, create_job, update_job, delete_job |
| Works | list_works, get_work, create_work, update_work, delete_work, approve_work_goal, approve_work_plan, work_tasks, stop_work, continue_work, work_message |
| Peers | peer_publish, peer_lookup, peer_list |
A few worth knowing:
create_session_withtakes apreamble— system instructions for this session on top of the agent's own. Leavetitleunset: Attacca titles a session from its first message, and a title given here is permanent.turn_eventsis a streaming tool. Its head is the session's current status and last cursor; items are durable events with cursors, deltas and status changes. Passafterto resume from a cursor; omit it for live frames only.session_historyreads the same events back as a list, for a node that was not connected when they happened. Here, omittingaftermeans the whole history.peer_lookuptakes a node path (laptop/zyris-code/myrepo) and returns that node's direct address, so two of an account's machines can transfer files without the server carrying the bytes.
Tools are added within version 1 rather than by bumping it. A node built against a newer
zyris-attacca may find a tool missing from an older deployment and get capability_not_announced;
treat that as "this deployment does not have it yet".
Without the crate
Depending on zyris-attacca is a convenience. Matching is by (name, version) and the tool list is
never compared, so a node can declare just the tools it calls with its own
#[zyris::capability(name = "attacca_api", version = 1)] trait. That is also how to consume any
capability nobody has published a crate for.