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 field | Type | Notes |
|---|---|---|
user_id | String | |
email | String | |
display_name | String | |
scopes | Vec<String> | The grant, as the wire spells it. Strings, so a scope newer than this crate still decodes. |
plan | Option<String> | The billing plan, as the deployment names it. Absent where nothing is metered. |
credits | Option<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 field | Type |
|---|---|
id | String |
name | String |
description | Option<String> |
model | Option<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 field | Type | Notes |
|---|---|---|
id | String | |
name | String | |
description | Option<String> | |
is_default | bool | The account's undeletable fallback. Resources created without a project land here; exactly one project per account has this set. |
| Tool | Behaviour |
|---|---|
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) -> ZProject | Read one. |
create_project(project: ZNewProject) -> ZProject | ZNewProject { name, description: Option }. |
update_project(project_id, update: ZProjectUpdate) -> ZProject | Omitted 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 field | Type | Notes |
|---|---|---|
id | String | |
title | Option<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_id | Option<String> | |
project_id | Option<String> | |
running | bool | Whether a turn is running now. |
preamble | Option<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 field | Type | Notes |
|---|---|---|
agent_id | String | The session's agent. |
title | Option<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_id | Option<String> | Omit for the account's default project. |
preamble | Option<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 field | Notes |
|---|---|
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 field | Type | Notes |
|---|---|---|
model | Option<String> | The model the turns ran on. None before the first turn. |
context_tokens | Option<i64> | Tokens currently in context — what the next turn starts from, not a running total. |
input_tokens, output_tokens, total_tokens | Option<i64> | |
credits_used | Option<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 field | Type | Notes |
|---|---|---|
session_id | String | |
message | String | |
agent_id | Option<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) | Type | Notes |
|---|---|---|
session_id | String | |
running | bool | Whether a turn is running at subscription time. |
last_cursor | Option<i64> | The session's latest durable cursor. |
ZTurnFrame is tagged by type on the wire:
| Variant | Wire | Meaning |
|---|---|---|
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:
| Field | Type | Notes |
|---|---|---|
id | Option<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. |
seq | i64 | Position in the timeline. |
cursor | i64 | Resume position for after. |
kind | String | Defined by the deployment — chat_user for the person's message and chat_agent for an answer are the kinds this API names. |
payload | serde_json::Value | The event body, shaped by kind. |
created_at | Option<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 field | Type | Notes |
|---|---|---|
cursor | Option<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. |
chars | u64 | How much of that answer was delivered, in Unicode scalar values — str::chars().count(), not bytes. |
delivered | Effect |
|---|---|
None | Just 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}.