ZyrisDocs

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.

Cargo.toml
[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

AreaTools
Identityme
Agentslist_agents, create_agent
Projectslist_projects, get_project, create_project, update_project, delete_project
Sessionslist_sessions, create_session, create_session_with, session_history, session_usage
Turnssend_message, cancel_turn, turn_events (streaming)
Jobslist_jobs, get_job, create_job, update_job, delete_job
Workslist_works, get_work, create_work, update_work, delete_work, approve_work_goal, approve_work_plan, work_tasks, stop_work, continue_work, work_message
Peerspeer_publish, peer_lookup, peer_list

A few worth knowing:

  • create_session_with takes a preamble — system instructions for this session on top of the agent's own. Leave title unset: Attacca titles a session from its first message, and a title given here is permanent.
  • turn_events is a streaming tool. Its head is the session's current status and last cursor; items are durable events with cursors, deltas and status changes. Pass after to resume from a cursor; omit it for live frames only.
  • session_history reads the same events back as a list, for a node that was not connected when they happened. Here, omitting after means the whole history.
  • peer_lookup takes 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.

On this page