Writing capabilities
Declare tools with one trait, describe them for a model, return errors, and stream results.
One trait, one capability
You can combine your own capability with ready-made implementations on the same node. See Capability addons before rebuilding standard file, shell or desktop tools.
#[zyris::capability(name = "notes", version = 1)]
pub trait Notes {
/// Find notes whose title or body contains `query`. Returns at most `limit` results,
/// newest first.
async fn search(&self, query: String, limit: Option<u32>) -> zyris::Result<Vec<Note>>;
/// Create a note and return it with its assigned id.
async fn create(&self, title: String, body: String) -> zyris::Result<Note>;
}#[zyris::capability(name = "…", version = N)] turns that one declaration into:
| Generated | Use |
|---|---|
The trait itself, async and Send + Sync + 'static | Implement it with #[zyris::async_trait]. |
| A request struct per method | The method's arguments, as the tool's JSON Schema. |
notes_capability() | The CapabilityDescriptor that is announced. |
NotesServer<T> | What you pass to Node::builder().capability(..). |
NotesClient | A typed client for calling the tools on a peer. |
Every method becomes the tool notes.search, notes.create, and so on. Each must be async, take
&self, and return zyris::Result<T>.
Write for the model
Doc comments are part of the wire. The trait method's doc comment becomes the tool description, and schemars turns field doc comments into the JSON Schema descriptions a model reads before it decides what to send.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Note {
/// Stable identifier. Pass this to other `notes` tools.
pub id: String,
/// One-line title.
pub title: String,
/// Markdown body.
pub body: String,
}Argument and return types must implement Serialize, Deserialize and JsonSchema. Say what a
field means, what unit it is in, and what an empty value does — a model cannot ask.
Implementing it
struct MyNotes { /* your storage */ }
#[zyris::async_trait]
impl Notes for MyNotes {
async fn search(&self, query: String, limit: Option<u32>) -> zyris::Result<Vec<Note>> {
if query.trim().is_empty() {
return Err(zyris::Error::invalid_params("query must not be empty"));
}
let limit = limit.unwrap_or(20) as usize;
Ok(self.find(&query).into_iter().take(limit).collect())
}
async fn create(&self, title: String, body: String) -> zyris::Result<Note> {
self.insert(title, body)
.map_err(|e| zyris::Error::internal(e.to_string()))
}
}Serve it by handing the generated server to the builder:
Node::builder()
.name("notes")
.capability(NotesServer(MyNotes { /* … */ }))
.build()?;A node can serve any number of capabilities; call .capability(..) once for each.
Errors
A tool fails by returning a zyris::Error, which travels as an err envelope with a string code,
a message, and an explicit retriable flag.
use zyris::{Error, ErrorCode};
Error::invalid_params("offset is past the end of the file"); // bad arguments
Error::internal("database unavailable"); // something broke on our side
Error::new(ErrorCode::Timeout, "the build did not finish"); // any registered code
Error::new(ErrorCode::Other("quota_exceeded".into()), "…") // a code of your own
.retriable(false);Every registered code has a conventional retriable default (timeout, overloaded,
node_offline, connection_lost and stream_lagged are retriable; the rest are not), and
.retriable(..) overrides it for one instance. .with_data(..) attaches a structured payload.
A capability-specific code must not collide with the protocol's registry — see Errors.
Streaming tools
A tool marked #[zyris(uni_stream)] answers immediately with a head — metadata such as a file's
size or a process's status — and then sends items until it finishes.
use zyris::Streaming;
#[zyris::capability(name = "logs", version = 1)]
pub trait Logs {
/// Follow a log file. The head is the file's current length in bytes; each item is one line.
#[zyris(uni_stream)]
async fn follow(&self, path: String) -> zyris::Result<Streaming<u64, String>>;
}
#[zyris::async_trait]
impl Logs for MyLogs {
async fn follow(&self, path: String) -> zyris::Result<Streaming<u64, String>> {
let len = self.len(&path)?;
let lines = self.lines(path); // impl Stream<Item = zyris::Result<String>> + Send + 'static
Ok(Streaming::new(len, lines))
}
}The return type must be zyris::Result<zyris::Streaming<Head, Item>>. Items are paced by the
receiver's flow-control credit, so a slow consumer slows the stream down instead of piling bytes up
in memory. Returning an Err before the stream starts sends a plain error and no items; an Err
item ends the stream with that error.
Reconnects are deliberately lossy: an open stream dies with its connection. Give a streaming tool a way to resume — an offset or a cursor argument — and say so in its doc comment.
bi_stream is reserved in protocol v1 and rejected by the macro, as are video tools for now.
Call limits
By default a caller applies its own timeout. A tool that legitimately runs long can say how long a caller should wait:
/// Build the workspace. Takes several minutes on a cold cache.
#[zyris(limit = 900)] // wait up to 900 seconds
async fn build(&self) -> zyris::Result<BuildReport>;
/// Run the full test suite.
#[zyris(limit = false)] // wait for as long as the connection lives
async fn test(&self) -> zyris::Result<TestReport>;limit = false still ends when the connection does — the heartbeat keeps running — but it removes
the clock on a node that is alive and working. A tool that asks for it is asking to be trusted not
to deadlock. limit = 0 and limit = true are compile errors.
Versioning
A capability is identified by (name, version).
- Adding a tool within a version is allowed. Consumers discover tools from the descriptor, so an older caller just never asks for the new one.
- Changing or removing a tool, or changing its arguments incompatibly, needs a new version.
- A node may announce two versions at once — two
.capability(..)calls with differentversion— while its callers migrate.
Calling a capability on a peer
The generated client works in the other direction. Given a Connection (from on_connect or
Node::dial), ask for it once the peer has announced the capability:
use std::time::Duration;
// The client implements the `Notes` trait, so the trait must be in scope to call its methods.
let notes: NotesClient = conn.wait_capability(Duration::from_secs(5)).await?;
let hits = notes.search("zyris".into(), Some(5)).await?;A capability nobody has published a crate for can be consumed this way too: declare the slice you
call as your own #[zyris::capability] trait with the same name and version. Matching is by
(name, version); the tool lists are never compared.