ZyrisDocs
attacca_api reference

Sessions and turns

attacca_api identity, agents, projects and sessions — sending messages, streaming turn_events, resuming, cancelling and reading usage.

The conversational half of attacca_api: who the node is, which agents and projects the account has, and how to hold a conversation with an agent. All examples take an &AttaccaApiClient obtained as in Getting the client.

Identity

me() -> ZMe

Who the connection is authorized as and what it was granted. Requires no scope, so it answers even for a node granted nothing.

ZMe fieldTypeNotes
user_idString
emailString
display_nameString
scopesVec<String>The grant, as the wire spells it. Strings, so a scope newer than this crate still decodes.
planOption<String>The billing plan, as the deployment names it. Absent where nothing is metered.
creditsOption<String>Credit balance, formatted by the deployment — display it, do not parse it.

has(ZScope) and known_scopes() are covered under scopes.

Agents

list_agents() -> Vec<ZAgent>

The caller's agents.

create_agent(agent: ZNewAgent) -> ZAgent

ZNewAgent has name and optional description and model. ZAgent adds the id:

ZAgent fieldType
idString
nameString
descriptionOption<String>
modelOption<String>

Projects

A project is the account-level folder a session, board or job belongs to. Nothing runs in it, so there is nothing to configure beyond its name.

ZProject fieldTypeNotes
idString
nameString
descriptionOption<String>
is_defaultboolThe account's undeletable fallback. Resources created without a project land here; exactly one project per account has this set.
ToolBehaviour
list_projects() -> Vec<ZProject>The default first, then the rest oldest-first. The default is created on demand, so the list is never empty.
get_project(project_id) -> ZProjectRead one.
create_project(project: ZNewProject) -> ZProjectZNewProject { name, description: Option }.
update_project(project_id, update: ZProjectUpdate) -> ZProjectOmitted fields are left alone.
delete_project(project_id) -> ()Refused while the project still holds sessions, boards or jobs, and refused outright for the default project. Move or delete the contents first.

ZProjectUpdate::description is Option<Option<String>> because it has three meanings: omitted (None) leaves the description alone, Some(None) sends an explicit null and clears it, and Some(Some(text)) replaces it.

use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZNewProject, ZProjectUpdate};

async fn projects(api: &AttaccaApiClient) -> zyris::Result<()> {
    let project = api
        .create_project(ZNewProject { name: "Rollout".into(), description: Some("Q3".into()) })
        .await?;

    // Rename only: the description is left alone.
    let rename = ZProjectUpdate { name: Some("Rollout 2".into()), ..Default::default() };
    api.update_project(project.id.clone(), rename).await?;

    // Clear the description: Some(None) sends an explicit null.
    let clear = ZProjectUpdate { description: Some(None), ..Default::default() };
    api.update_project(project.id.clone(), clear).await?;

    api.delete_project(project.id).await
}

Sessions

ZSession fieldTypeNotes
idString
titleOption<String>An untitled session reads back under Attacca's placeholder, not as absent. The title agent replaces it once the session has a first message, so a title is only final for a session that has already taken a turn.
agent_idOption<String>
project_idOption<String>
runningboolWhether a turn is running now.
preambleOption<String>The session's own system instructions. Absent from a deployment that predates the field.

list_sessions(filter: ZSessionFilter) -> Vec<ZSession>

ZSessionFilter { project_id: Option, limit: Option<u32> }; ZSessionFilter::default() lists everything.

create_session_with(session: ZNewSession) -> ZSession

ZNewSession fieldTypeNotes
agent_idStringThe session's agent.
titleOption<String>Prefer leaving it unset. Attacca titles a session from its first message, in that message's language. A title given here is permanent and opts the session out of auto-titling — set it only for a session a human will look for under a name the node already knows.
project_idOption<String>Omit for the account's default project.
preambleOption<String>System instructions for this session alone, appended to the agent's own on every turn. The agent keeps its identity, tools and skills; this narrows what it is doing here. Fixed for the session's lifetime — different instructions need a different session.

create_session(agent_id, title, project_id) -> ZSession

The same call without preamble, kept for nodes built before create_session_with. Pass title as None for the reason above.

session_history(session_id, query: ZHistoryQuery) -> Vec<ZSessionEvent>

A session's durable timeline, oldest-first: the same events turn_events streams, read back as a list for a node that was not connected when they happened.

ZHistoryQuery fieldNotes
after: Option<i64>Only entries past this cursor, exclusive — the cursor of the last entry already seen, from this tool or from turn_events. Omit for the whole timeline.
limit: Option<u32>At most this many, taken oldest-first from after. Omit for everything.

Mind the one difference from turn_events: omitting after here means the whole history; there it means live frames only.

use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZHistoryQuery, ZSessionFilter};

async fn print_recent(api: &AttaccaApiClient, project_id: String) -> zyris::Result<()> {
    let filter = ZSessionFilter { project_id: Some(project_id), limit: Some(10) };
    for session in api.list_sessions(filter).await? {
        let title = session.title.as_deref().unwrap_or("(untitled)");
        println!("{} {title} running={}", session.id, session.running);

        let history = api.session_history(session.id.clone(), ZHistoryQuery::default()).await?;
        for event in history {
            println!("  [{}] {} {}", event.cursor, event.kind, event.payload);
        }
    }
    Ok(())
}

session_usage(session_id) -> ZUsage

What the session has cost so far. Requires sessions:read. Every field is optional, and ZUsage::default() is a legitimate answer meaning "metered nothing".

ZUsage fieldTypeNotes
modelOption<String>The model the turns ran on. None before the first turn.
context_tokensOption<i64>Tokens currently in context — what the next turn starts from, not a running total.
input_tokens, output_tokens, total_tokensOption<i64>
credits_usedOption<String>Formatted by the deployment, like ZMe::credits.

session_usage was added within version 1. An older deployment answers capability_not_announced; treat that as "this deployment does not meter", not as a connection failure.

Sending a message

send_message(session_id, message, data: Vec<Datum>) -> ()

Posts a message, starting a turn. It answers nothing; the turn's output arrives on turn_events. data carries attachments as datums; pass vec![] for none.

send_message_with(message: ZNewMessage) -> ()

send_message with room for options. It carries no data: a message with files goes through send_message.

ZNewMessage fieldTypeNotes
session_idString
messageString
agent_idOption<String>The agent that answers this one message in place of the session's own — the per-message override the web app's agent picker sends. Omit to keep the session's agent.

A voice node, for instance, uses agent_id so that whichever session a person picked, the answer comes from an agent written to be read aloud. The tool was added within version 1, so fall back to send_message — and the session's agent — when the deployment does not have it:

use zyris::ErrorCode;
use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZNewMessage};

async fn say_with(
    api: &AttaccaApiClient,
    session_id: String,
    message: String,
    agent_id: String,
) -> zyris::Result<()> {
    let request = ZNewMessage {
        session_id: session_id.clone(),
        message: message.clone(),
        agent_id: Some(agent_id),
    };
    match api.send_message_with(request).await {
        Err(e) if matches!(e.code, ErrorCode::CapabilityNotAnnounced | ErrorCode::MethodNotFound) => {
            // An older deployment: fall back to the session's own agent.
            api.send_message(session_id, message, vec![]).await
        }
        other => other,
    }
}

Streaming a turn

turn_events(session_id, after: Option<i64>) -> Streaming<ZTurnStatus, ZTurnFrame>

The live turn feed with cursor resume, and the only streaming tool on the capability (a uni-stream). Requires events:read.

  • after: None — live frames only, from now on.
  • after: Some(cursor) — resume past that durable cursor, so events missed while disconnected are not lost.

The returned Streaming has a head, available immediately, and items, a stream of zyris::Result<ZTurnFrame>.

ZTurnStatus (head)TypeNotes
session_idString
runningboolWhether a turn is running at subscription time.
last_cursorOption<i64>The session's latest durable cursor.

ZTurnFrame is tagged by type on the wire:

VariantWireMeaning
Event { cursor, event }{"type":"event","cursor":7,"event":{…}}A durable event, with the cursor to resume after.
Delta { kind, text }{"type":"delta","kind":"assistant","text":"…"}A piece of text still streaming. kind is Assistant or Reasoning. Not durable and carries no cursor; the finished answer arrives as an Event.
Cancelled{"type":"cancelled"}The turn was stopped, not finished. Sent before the Status { running: false } that ends it — and also when a delivery point cut an already-finished turn.
Status { running }{"type":"status","running":false}The session's running flag changed.

The subscription stays open across turns. To wait for one turn, subscribe before send_message and stop at Status { running: false }, as the overview example does.

Session events

ZSessionEvent is what Event frames and session_history carry:

FieldTypeNotes
idOption<String>The event's stable id. An event arriving with an id already held under another seq has moved — a message re-sequenced to where the turn received it — so drop the old entry. Absent from a deployment that predates the field.
seqi64Position in the timeline.
cursori64Resume position for after.
kindStringDefined by the deployment — chat_user for the person's message and chat_agent for an answer are the kinds this API names.
payloadserde_json::ValueThe event body, shaped by kind.
created_atOption<String>RFC 3339. Absent from a deployment that predates the field.

Resuming after a reconnect

A connection's client and stream die with it. Keep what you have seen outside the connection, and on the next on_connect subscribe again from the last durable cursor:

use futures::StreamExt;
use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZSessionEvent, ZTurnFrame};

/// A transcript kept across reconnects: events by id, and the last durable cursor seen.
#[derive(Default)]
struct Transcript {
    events: Vec<ZSessionEvent>,
    last_cursor: Option<i64>,
}

impl Transcript {
    fn apply(&mut self, cursor: i64, event: ZSessionEvent) {
        // An id already held under another seq means the event moved: drop the old entry.
        if let Some(id) = &event.id {
            self.events.retain(|held| held.id.as_ref() != Some(id));
        }
        self.events.push(event);
        self.events.sort_by_key(|event| event.seq);
        self.last_cursor = Some(self.last_cursor.map_or(cursor, |last| last.max(cursor)));
    }
}

async fn follow(api: &AttaccaApiClient, session_id: String, log: &mut Transcript) -> zyris::Result<()> {
    let mut stream = api.turn_events(session_id, log.last_cursor).await?;
    println!("running={} last_cursor={:?}", stream.head.running, stream.head.last_cursor);

    while let Some(frame) = stream.items.next().await {
        match frame? {
            ZTurnFrame::Event { cursor, event } => log.apply(cursor, event),
            ZTurnFrame::Delta { .. } => {} // live text; the durable answer arrives as an Event
            ZTurnFrame::Cancelled => println!("turn stopped"),
            ZTurnFrame::Status { running } => println!("running={running}"),
        }
    }
    Ok(())
}

Persist the session ID and last_cursor somewhere that survives the process if the node may restart. session_history with the same after reads the gap without subscribing.

Cancelling a turn

cancel_turn(session_id, delivered: Option<ZDelivered>) -> ()

Stops the running turn and keeps only what the person got. Whatever answer text streamed before the stop is kept. Subscribers see Cancelled, then Status { running: false }.

delivered tells Attacca how far the answer actually reached the person — a voice node that was interrupted mid-sentence, say — and the stored answer is cut to match:

ZDelivered fieldTypeNotes
cursorOption<i64>The chat_agent event being delivered, as the cursor of the Event frame it arrived in. None: the answer still streaming as Delta frames, not yet an event.
charsu64How much of that answer was delivered, in Unicode scalar values — str::chars().count(), not bytes.
deliveredEffect
NoneJust stop.
Some { cursor: None, chars }Cut the answer still streaming to chars.
Some { cursor: Some(c), chars }Cut that chat_agent event to chars and empty every later answer in its turn. A turn that already finished is cut too, and subscribers get Cancelled.

Refused with invalid_params: a cursor that is unknown, stale or not a chat_agent event, and cursor: None when no turn is running.

use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZDelivered};

/// The person interrupted after hearing `spoken` of the answer. `answer_cursor` is the cursor of
/// the `chat_agent` Event being read out, or `None` while it was still arriving as Delta frames.
async fn interrupt(
    api: &AttaccaApiClient,
    session_id: String,
    answer_cursor: Option<i64>,
    spoken: &str,
) -> zyris::Result<()> {
    let delivered = ZDelivered { cursor: answer_cursor, chars: spoken.chars().count() as u64 };
    api.cancel_turn(session_id, Some(delivered)).await
}

/// Just stop; keep whatever streamed.
async fn stop(api: &AttaccaApiClient, session_id: String) -> zyris::Result<()> {
    api.cancel_turn(session_id, None).await
}

Breaking on main

cancel_turn took only session_id in 0.2.0. Code calling cancel_turn(id) must now pass cancel_turn(id, None). On the wire an old request without delivered still decodes as a plain stop, and ZDelivered { cursor: None, chars: 5 } serializes as {"chars":5}.

On this page