AGDX
The portable data and agent contract under Laser SDK: records, message rules, delivery, trust, capabilities, and conformance
This is the reference for implementers. To build agents with the SDK, start with Agents and Sessions.
AGDX (Agent Data Exchange Protocol) defines the records and rules for streaming, managed data, and agent coordination. Laser SDK implements it on Apache Iggy's durable partitioned log. Iggy supplies transport, retention, and consumer groups. The SDK records which model produced an output, but it never calls a model.
The specification and the AGDX reference in the laser-sdk repository define every field, code, limit, and byte layout. This page covers what an implementer or an application author needs to act on.
One contract, five layers
| Layer | Owns | Can be used alone |
|---|---|---|
| Substrate | Durable partitions, offsets, retention, consumer groups, pull-based reads | Laser SDK uses Apache Iggy |
| Wire | Portable types, named-field CBOR, dictionaries, limits, capability shapes, fixtures | Yes, by an independent port |
| Platform | Publish and consume, projections and query, key-value, forks, graph | Yes, with no agent concepts |
| Fabric | Agent envelopes, sessions, reliable consumers, coordination, context, memory, governance | Yes, with no public edge protocol |
| Edges | A2A, MCP, and AG-UI mappings | Only when an external client needs that contract |
The log is the source of truth. Projections, indexes, state, graph data, session indexes, and agent registries all derive from its records. Applications do not keep separate stores in sync for these features.
Envelope anatomy
Each typed agent record carries a named-field CBOR AgentEnvelope as its payload. Routing information that a reader needs before decoding rides in Iggy headers outside the envelope.
Each field has one authoritative location. One shared encoder writes exactly these headers on every agent record:
| Header | Meaning |
|---|---|
agdx.ct | Content type of the body |
agdx.av | Envelope version |
gen_ai.conversation.id | The conversation, which is also the partition key |
agdx.parent_conv and agdx.root_conv | Parent and root of a child session |
gen_ai.agent.id | The author |
agdx.to | The addressee, an agent ID or * for every agent |
IDs ride in headers as canonical strings. Every record on a shared session topic carries agdx.to, and a reader never parses * as an agent ID. Headers have a soft limit of 1,024 bytes per record. Each value is at most 255 bytes, and each header costs 9 framing bytes toward the total.
command {
kind: command
record: 01J...
conversation: 01J... # partition key and trace identity
source: planner # a claim unless verified
target: summarizer
correlation: 01J... # request and reply pairing
operation: summarize
body: <bytes> # decoded using agdx.ct
signature: <optional>
}| Field family | Fields | Purpose |
|---|---|---|
| Identity and routing | kind, record, conversation, parent, root, source, target | What this is, which session and session tree it belongs to, who claims to have sent it, and who it is for |
| Causality | cause, cause_at, correlation | Parent record, optional native log position, request and reply pairing |
| Chunk lifecycle | channel, sequence, last, finish_reason | Ordered streaming and deterministic reassembly |
| Execution | operation, tool, task_state, deadline_micros, idempotency_key | What is happening and which safety rules apply |
| Content | body, content type header | Opaque payload bytes and their codec |
| Accounting and policy | usage, metadata | Advisory token usage and cost, and pinned policy or routing context |
| Evolution and integrity | must_understand, signature | Strict feature handling and optional verified authorship |
Machine IDs are 128-bit values, shown as 26-character Crockford base32 strings. CBOR encodes them as 16-byte big-endian byte strings. cause identifies the parent record portably, and the optional cause_at gives its Iggy log position for a direct local lookup.
must_understand is a bitset of features a receiver must implement to process the record. A receiver rejects or dead-letters a record with a bit it does not know. No bits are defined yet, and 0, the default, means ignore anything unknown.
usage.cost_micros is the cost in millionths of the deployment's currency. Producers multiply a decimal amount by one million, round half up, and reject values outside the unsigned 64-bit range. Like all usage, it is advisory.
Envelope limits
| Limit | Value |
|---|---|
operation, tool, finish_reason, each | 256 bytes |
| Idempotency key | 64 bytes |
| Metadata entries, key, value, total | 32 entries, 256 bytes, 1,024 bytes, 8,192 bytes |
| Body reference | 1,024 bytes |
| Agent card capabilities | 64 |
| Chunk body | 64 KiB |
Pinned metadata keys
| Key | Meaning |
|---|---|
role | Chat role, such as user, assistant, system, or tool |
bridge_hops | The bridge loop guard, a list of bridge IDs |
submitted | Marks the first command of a submitted session |
gen_ai.request.model, gen_ai.response.model, gen_ai.provider.name | The requested model, the model that answered, and the provider |
duration_micros | Duration of a model or tool call, measured by the application |
on_behalf_of | The user an agent acts for |
purpose, data_classification, task_context, session_intent | Policy inputs. Advisory unless the envelope is signed |
Legal message shapes
The message kind decides which fields are required and which are forbidden. The wire decoder, the SDK constructors, and receivers enforce the same rules.
| Kind | Required core | Meaning |
|---|---|---|
command | record, conversation, source, correlation, body | Requests a reply or an effect. A command without a correlation is invalid |
response | record, conversation, source, correlation, body | Answers the command with the same correlation |
event | record, conversation, source, body | Announces something and expects no reply |
chunk | conversation, source, correlation, channel, sequence, body (the terminal chunk can be empty) | Carries one ordered part of chat, reasoning, or tool_args |
status | record, conversation, source, operation | Carries task, session, card, progress, quarantine, or unquarantine state |
error | record, conversation, source, correlation, body | Ends a request, and optionally a chunk channel, with a typed error |
R means required, O optional, and X forbidden:
| Field | command | response | event | chunk | status | error |
|---|---|---|---|---|---|---|
correlation | R | R | O | R | O (R for task) | R |
channel, sequence | X | X | X | R | X | O (stream terminal) |
last | X | X | X | O | O | X |
finish_reason | X | O | X | O (with last) | X | X |
idempotency_key | O | O | O | X | X | X |
deadline_micros | O | X | X | O (opening chunk) | X | X |
task_state | X | O | X | X | R (task or session) | O |
operation | O | O | O | R on the opening chunk, X after | R | O |
tool | O | O | O | O | X | O |
usage | X | O | O | O (terminal chunk) | O | O |
More rules:
- A
statuswith operationtaskneeds a correlation and a task state. - A
statuswith operationsessionneeds a task state and a CBOR body: a start, a transition, or an end. Itslastmust equal whether the task state is terminal, so an end sets it and a start or transition must not. - The opening chunk, sequence
0, carries the purposechat,reasoning, ortool_args. Later chunks must not. rootneedsparent, and neither may equal the record's own conversation.
Use the typed SDK constructors instead of raw maps. They apply these rules before publishing.
Streams of chunks
Chunks share a channel and are ordered by sequence within one conversation partition. A stream ends with a chunk that sets last and a finish_reason, or with an error that names the channel. Readers reassemble with the same rules everywhere:
- Apply chunks in order from sequence 0, once per sequence.
- Drop duplicate sequences and count them.
- On a gap, end the local stream with finish reason
gap. - Accept only the first terminal record, and drop and count records after it.
- Carry whole-stream usage once, on the terminal chunk.
The reader creates the gap and abandoned outcomes locally, abandoned when the opening chunk's deadline passes. Neither is written back to the log, and replay returns the original records. Offsets let a reader resume.
Produce the same command in every SDK
import {
AgentId,
AgentTopic,
ConversationId,
CorrelationId,
MintUlid
} from "@laserdata/laser-sdk"
const text = new TextEncoder()
const conversation = ConversationId.new()
const correlation = MintUlid.mint(CorrelationId)
const record = await laser
.agdx(AgentTopic.Sessions, AgentId.new("planner"), conversation)
.command(correlation, text.encode("summarize incident 42"))
.withOperation("summarize")
.send()use laser_sdk::prelude::full::*;
use laser_sdk::types::MintUlid;
use laser_sdk::wire::agent::{ConversationId as WireConversationId, CorrelationId};
let conversation = ConversationId::new();
let correlation = CorrelationId::mint();
let record = laser
.agdx(
AgentTopic::Sessions,
"planner".parse::<AgentId>()?,
WireConversationId::from(conversation),
)
.command(correlation, b"summarize incident 42".to_vec())
.with_operation("summarize")
.send()
.await?;import laser_sdk as ls
conversation = ls.new_conversation_id()
correlation = ls.mint_ulid()
record = await laser.agdx(
ls.AgentTopic.Sessions,
"planner",
conversation,
).command(
correlation,
b"summarize incident 42",
operation="summarize",
)Each client publishes the same logical command envelope and returns the generated record ID. Rust send() returns Option<RecordId> and TypeScript returns RecordId | undefined. Python's verbs publish at once and return the ID, with no send(). Rust mints a correlation with CorrelationId::mint() from the MintUlid trait, TypeScript with MintUlid.mint(CorrelationId), and Python with ls.mint_ulid(). In Rust, WireConversationId is laser_wire::agent::ConversationId, imported under that name. The worker answers with respond and the same correlation, or opens a chunk stream for incremental output.
Send builders refine the envelope before it is published:
| Refinement | Rust | TypeScript | Python keyword |
|---|---|---|---|
| Cause record and position | with_cause(record, Some(position)) | withCause(record, position) | cause= and cause_at=ls.LogPosition(stream_id, topic_id, partition_id, offset) |
| Parent and root session | with_ancestry(..) | withAncestry(..) | parent= and root= |
| Deadline in epoch microseconds | with_deadline_micros(n) | withDeadlineMicros(n) | deadline_micros= |
| Idempotency key | with_idempotency_key(key) | withIdempotencyKey(key) | idempotency_key= |
| Metadata entry | with_metadata(key, value) | withMetadata(key, value) | metadata= with a dict |
| Tool name | with_tool(name) | withTool(name) | tool= |
| Token usage | with_usage(usage) | withUsage(usage) | usage= |
| Finish reason | with_finish_reason(reason) | withFinishReason(reason) | finish_reason= |
| Target agent | with_target(agent) | withTarget(agent) | target= |
| Operation name | with_operation(name) | withOperation(name) | operation= on command, respond, emit, and fail. Positional on status |
| Content type | content_type(type) | contentType(type) | content_type= |
| Claim check | claim_check(&store, threshold) | claimCheck(store, threshold) | claim_check=(store, threshold_bytes) |
| Signing | signed_by(&key) | signedBy(key) | signed_by= per send, or signing_key= on laser.agdx(..) |
Rust and TypeScript builders also have with_task_state, with_correlation, last(), and body() (camelCase in TypeScript). Python passes refinements as keywords to command, respond, emit, status, and fail. All five take operation=, target=, task_state=, last=, and a correlation, which is positional on command, respond, and fail and a keyword on emit and status. status keeps its operation positional in every SDK. fail takes no content type, because its body is the CBOR AgentErrorBody. Rust and TypeScript refuse an error envelope relabeled with another content type with an invalid error at send. Rust fail returns a Result, so it needs ? before the builder calls. A cause position needs a cause record ID. The per-kind rules still apply, so a refinement the kind forbids fails the send.
Large bodies
claim_check(store, threshold) stores a large body outside the log and replaces it with a BodyRef. At or above the threshold, the store receives the body and the record carries the reference, size, and SHA-256 digest, with content type ref. Below it, the body stays inline. The SDK has no default blob store. A store implements put and get. The standalone check_in(store, threshold, payload) and resolve_body(store, payload) apply the same rule without a connection (TypeScript: checkIn and resolveBody). resolve_body checks the size and then the SHA-256 digest before it returns bytes, and a mismatch is an integrity error. For a received message, message.resolve_body(store) in Rust and Python and agentMessageResolveBody(message, store) in TypeScript start from the message body, which is the envelope body for an AGDX record. They fetch and check the stored bytes when the content type is ref, and return the body unchanged otherwise.
A BodyRef reference is a URI, object key, or key-value key of at most 1,024 bytes, with a size, a 32-byte SHA-256 digest, and optional encryption details. A consumer must compare fetched bytes with the digest.
Chunk writers
Chunk writers stream incremental output under one correlation. The purpose is chat, reasoning, or tool_args, and it rides the opening chunk. A chunk body is at most 64 KiB. A writer can take a target, a content type, an opening deadline, and bounded buffering. buffered(max_chunks, linger) takes a Duration in Rust and milliseconds in TypeScript and Python. flush() publishes pending chunks, and the terminal call, finish or fail, always flushes. Linger is checked on writes, so an idle writer keeps its pending chunks until a write, a flush, or the terminal call.
const stream = laser
.agdx(AgentTopic.Streams, AgentId.new("summarizer"), conversation)
.stream(correlation, "chat")
.buffered(32, 20)
await stream.write(text.encode("The incident "))
await stream.write(text.encode("is resolved."))
await stream.finish("stop")use std::time::Duration;
let mut stream = laser
.agdx(
AgentTopic::Streams,
"summarizer".parse::<AgentId>()?,
WireConversationId::from(conversation),
)
.stream(correlation, "chat")
.buffered(32, Duration::from_millis(20));
stream.write(b"The incident ".to_vec()).await?;
stream.write(b"is resolved.".to_vec()).await?;
stream.finish("stop", None).await?;stream = laser.agdx(
ls.AgentTopic.Streams,
"summarizer",
conversation,
).stream(correlation, "chat").buffered(32, 20)
await stream.write(b"The incident ")
await stream.write(b"is resolved.")
await stream.finish() # finish_reason defaults to "stop"Rust finish(reason, usage) takes an optional usage. TypeScript finish(reason, usage?) and Python finish(finish_reason="stop", usage=None) match it. Signing works on the command, response, event, status, and error verbs. The request_input helper and the chunk writer do not sign records.
Read an agent message
A handler receives an AgentMessage. Routing and accounting fields live on message.provenance, the decoded envelope on message.envelope, and the envelope body behind body() (TypeScript: agentMessageBody(message)). id is the message's log position. The provenance carries the sender as agent, the conversation, correlation, and idempotency key, the deadline (deadline in Rust and Python, deadlineMicros in TypeScript), and advisory token usage as an LlmUsage in usage. Token counts are optional, and bigint in TypeScript.
import { type AgentCtx, type AgentMessage, agentMessageBody } from "@laserdata/laser-sdk"
const summarizer = {
handle: async (message: AgentMessage, ctx: AgentCtx) => {
const sender = message.provenance.agent?.asStr() ?? "unknown"
const tokens = message.provenance.usage?.inputTokens ?? 0n
const body = agentMessageBody(message)
await ctx.respond(
new TextEncoder().encode(`${body.byteLength} bytes from ${sender}, ${tokens} tokens`)
)
}
}struct Summarizer;
impl AgentHandler for Summarizer {
async fn handle(
&self,
message: &AgentMessage,
ctx: &AgentCtx<'_>,
) -> Result<(), LaserError> {
let provenance = &message.provenance;
let sender = provenance.agent.as_ref().map_or("unknown", |agent| agent.as_str());
let tokens = provenance
.usage
.as_ref()
.and_then(|usage| usage.input_tokens)
.unwrap_or(0);
let body = message.body();
ctx.respond(format!("{} bytes from {sender}, {tokens} tokens", body.len()))
.await
}
}async def summarize(ctx, message):
provenance = message.provenance
sender = provenance.agent or "unknown"
tokens = (provenance.usage.input_tokens if provenance.usage else None) or 0
body = bytes(message.body())
await ctx.respond(f"{len(body)} bytes from {sender}, {tokens} tokens".encode())Python handlers receive (ctx, message), in that order.
Sessions
A session is one conversation with a recorded lifecycle. Its ID is the conversation ID. Sessions in depth shows the SDK surface. The records are ordinary envelopes:
| Record | Topic | Meaning |
|---|---|---|
status with operation session and task state Submitted | agent.sessions | A session handed to an agent, with its start body |
command with metadata submitted = true, operation invoke_agent by default | agent.sessions | The work handed to the agent in a submission |
status with operation session and task state Working | agent.sessions | A start, or a pickup or resume with a transition body |
status with operation session and task state Paused | agent.sessions | A participant acknowledging a pause request |
status with operation session and a terminal task state | agent.sessions | The end, with a reason and an optional structured error |
command with chat, text_completion, or generate_content | agent.sessions | A model request, answered by a response or error with the same correlation |
command with execute_tool | agent.sessions | A tool call, answered the same way |
event with state_delta or state_snapshot | agent.sessions | A JSON Patch delta or a whole-document snapshot of session state |
event with context_assembled, context_compacted, or context_retrieved | agent.sessions | What a model call received, what a summary replaced, and what memory was recalled |
event with policy_decision | agent.sessions | A governance decision |
status with operation progress | agent.heartbeats | One process heartbeat per stream, listing the sessions it holds |
command with session_pause, session_resume, session_cancel, or force_cancel | agent.control | An operator request. The same request on any other topic is never applied |
status with operation session, task state Canceled, and reason forced | agent.control | An operator ending a session whose agent is gone |
A start body names the agent, the SDK language and version, and optionally a label, namespace, parent and root, idle timeout, token and cost budget, and tags. A label is at most 256 bytes with no control characters.
Lifecycle
- The first terminal record on the session lane wins. A repeated identical terminal changes nothing. Records after it still count, and readers flag them
after_end. - A session without a start record is implicit: active, shown idle after its timeout, never completed.
- Readers map task states to statuses:
Submittedtosubmitted,Working,InputRequired, andAuthRequiredtoactive,Pausedtopaused,Completedtocompleted,Canceledtocanceled, andFailedandRejectedtofailed. - Idle and over budget are worked out at read time and are never terminal. An SDK that enforces a budget ends the session itself with reason
budget. - A dead-lettered record never fails a session by itself unless the runtime is configured to.
Heartbeats
A process that holds a lease on a session lists it in a heartbeat on agent.heartbeats, keyed by process. One record lists at most 2,048 sessions. A process beats every 60 seconds by default, or at one fifth of the shortest idle timeout it holds when that is shorter. The default idle timeout is 5 minutes, and agent.heartbeats expires records after one hour. A process killed without a terminal record shows idle, never failed.
Pause and resume
An operator's session_pause request names its participants, the agents whose acknowledgments complete the pause. Each acknowledges with a Paused status naming the exact request. An agent holds work that arrives while the session is paused: it appends a session_parked event before it commits the source and never treats parked work as handled. After session_resume, each agent acknowledges with a Working status, handles each held record at least once before new work, and appends session_unparked. A cancel while paused ends the session with the held records unhandled. Agents follow every partition of agent.control, outside their consumer groups. Operators may sign control records and agents may sign terminal records, and the managed index names a verified signer as verified_actor. No capability advertises the pause runtime yet.
Dispatch
A reliable consumer classifies every record before its handler sees it. Only a command for an operation the handler serves, addressed to this agent, to every agent, or to no agent, is work. Events, replies, status records, and records for other agents are skipped and committed. A control request counts only on agent.control. The author never decides the class, so an agent can send work to itself.
A reply answers a request only when it carries the request's correlation, belongs to the request's session, is a response or an error, and is addressed to the requester when the request named one. The request never answers its own wait.
State
Readers apply session state in lane order. A patch applies as a whole or not at all. A repeated delta applies once. A snapshot replaces the document only when it was written against the current revision, so a late snapshot cannot overwrite a newer change. A patch has at most 256 operations, a document at most 8 MiB, and JSON integers must fit the range that JavaScript represents exactly.
Managed reads
A deployment that registers a stream as a session source serves seven reads: list, get, events, state, links, sources, and changes. Every read names its stream first, and there is no list across streams. The HTTP routes sit under /agdx/sessions/{stream}. Replies carry the fold frontier of each source and report gaps, so a reader can tell a settled view from one still catching up. The change feed is a per-stream sequence that a reader polls with the last sequence it saw. List reads filter by status, agent, text, root, and label_prefix. A session summary reports held work and a liveness_unknown flag. Timelines use the display types session.parked, session.unparked, and invalid beside the others.
The SDKs read the index through get, list, events, state, links, sources, changes, and watch on the session factory. A failed read surfaces as a typed session error. Access needs session:read on stream:<name> and read permission on the whole stream. Registering a stream needs session:admin and the same stream-wide read permission. The server stamps the verified stream on every session request, and the managed backend refuses one without it.
A session factory remembers the stream and lane topic generations and the partition count it saw. Before a lane write, the SDK compares them with the current source and returns a stale session error when they changed.
Stream-scoped names
A stream is the boundary of one tenant. Managed names that belong to a stream are written stream:<stream>/<local>. A client with a default stream scopes key-value and memory namespaces, lease and fence namespaces, the key registry, graph names, projection and index IDs, query indexes, fork IDs, and change-feed index filters. A name that already starts with stream: passes through. Schema requests carry the stream, and the registry keys a schema by stream and ID. Range scans, bulk deletes, and graph reads carry the stream next to their conversation lens.
The server stamps the verified stream on every request that names such a resource, refuses an unresolved stream, and refuses a request or batch that names two streams. A deployment in stream tenancy mode announces stream_tenancy, rejects unscoped names, refuses role grants that span streams, and keeps each stream's change records and dead letters on that stream's own topics. Change records then carry their stream. See Stream-scoped resource names.
Identifiers and topics
| Need | Rust | TypeScript | Python |
|---|---|---|---|
| Agent ID as text | agent.as_str() | agent.asStr() | A plain str |
| Agent ID for an envelope | agent.wire_id() | agent.wireId() | A plain str |
| Fresh ULID-valued wire ID | CorrelationId::mint() with MintUlid | MintUlid.mint(CorrelationId) | ls.mint_ulid() |
| Well-known topic | AgentTopic::Sessions | AgentTopic.Sessions | ls.AgentTopic.Sessions |
| Any other topic | AgentTopic::Custom(&identifier) with an Iggy Identifier | AgentTopic.Custom(name) | The topic name as a str |
| Invalid ID | IdError | IdError | IdError |
| Invalid provenance headers | ProvenanceError | ProvenanceError | ProvenanceError |
The well-known topics and their names:
| Topic | Name | Holds |
|---|---|---|
Sessions | agent.sessions | The session lane: work, replies, model and tool records, lifecycle, state, and context |
Streams | agent.streams | Chunk streams |
Heartbeats | agent.heartbeats | Process heartbeats, with a one-hour expiry |
Control | agent.control | Operator control requests, writable by operators only |
Memory | agent.memory | Memory records |
Audit | agent.audit | Policy evidence |
Registry | agent.registry | Agent cards and registry facts |
WorkflowJournal | agent.workflow_journal | Workflow step outcomes |
Dlq | agent.dlq | Dead-letter capsules |
Both errors name the failure, such as an empty or invalid ID or a missing or oversized header. Rust reports it as the enum variant, TypeScript in the message, and Python in the error's kind.
Delivery, ordering, and replay
- Delivery is at least once. Commit the consumer offset after the handler succeeds. A crash before the commit causes a replay.
- Agent records use the session, which is the conversation ID, as their partition key. Each session is ordered, and separate sessions run in parallel.
- Lifecycle and state always stay on the session's partition of
agent.sessions. - A stream picks one layout for agent work: shared (the default), a topic per agent, a partition per agent, or a single partition. In the per-agent partition layout, a command, response, error, or chunk addressed to a declared agent lands on that agent's partition. A command is keyed by its addressee and a reply by its requester. In the per-agent topic layout, the same records sent to
agent.sessionsfor a declared agent go to that agent's declared topic, keyed by session, and a plain record with a target follows the same rule. Lifecycle, state, broadcast records, and records for undeclared agents stay on the lane in every layout. - Across partitions, readers order records by broker append time, then by stream, topic, partition, and offset. The producer's clock is never used for order.
- On a shared topic, a server with filtered reads can deliver each agent only its own and broadcast records through the filter
agdx.to In [<self>, "*"]. A filter is not an access boundary. Only separate topics with separate grants keep agents from reading each other's records. - Exactly-once effects need an application idempotency key and durable processed-key storage. They are not a delivery mode.
- An acknowledgment commits an offset. AGDX adds no ack, nack, visibility timeout, priority, or server-side retry protocol.
- Dead-letter records on
agent.dlqinclude the original record bytes, the source position, the reason, the attempt count, and optional detail.
Trust boundary
Agent-written fields are claims until they are authenticated. Routing information does not grant access.
| Signal | Safe reading |
|---|---|
source, target, usage, cost, policy metadata | Advisory on a shared unsigned topic |
| Verified envelope signature | The enrolled principal signed the canonical envelope |
| Signature context | Also binds the out-of-band content type and envelope version |
| Write-exclusive Iggy topic with ACLs | Authorship established by topology |
| Server-stamped user on managed commands | Trusted input to capability RBAC |
| Fence token checked by the state store | Rejects a stale lease holder before an effect |
target restricts routing without granting permission. If the registry has a signature verifier, quarantine and unquarantine records count only with a valid operator signature. Without a verifier, write access to the registry topic is the only gate.
Delegated work stores on_behalf_of in envelope metadata. It is a claim unless the envelope is signed. An action must pass both the agent's grants and the user's grants.
Signatures
A signature uses Ed25519 (scheme 1) with an 8-byte key ID. The signed input is the domain agdx.signature.v1, the encoded context when present, and the canonical envelope with its signature absent. A context binds agdx.ct and agdx.av, so changing either header breaks the signature. Keys bind to an authenticated principal, not to the claimed source. With a verifier configured, an unsigned reply, an unknown key, an invalid signature, or the wrong signer never resolves a correlated wait, and key validity is judged at the time the server recorded.
Enroll and verify signing keys
A KeyRegistry maps principals to their Ed25519 verifying keys. Pass it to an agent as its verifier to accept only envelopes that an enrolled key signed. A KeyRecord binds one key to a principal, a kind (agent or operator), and an optional validity window. from_verifying_bytes (TypeScript: fromVerifyingBytes) builds one from the 32 public-key bytes a signer published, and verifying reads the key back.
import { KeyKind, KeyRecord, KeyRegistry, SigningKey } from "@laserdata/laser-sdk"
const key = SigningKey.fromBytes(secret)
const registry = new KeyRegistry()
registry.enrollRecord(KeyRecord.fromVerifyingBytes("planner", key.verifyingKey(), KeyKind.Agent))
const principal = registry.verify(envelope)use laser_sdk::sign::{KeyKind, KeyRecord, KeyRegistry, SigningKey};
let key = SigningKey::from_bytes(&secret);
let mut registry = KeyRegistry::new();
registry.enroll_record(KeyRecord::from_verifying_bytes(
"planner",
key.verifying_key().as_bytes(),
KeyKind::Agent,
)?);
let principal = registry.verify(&envelope)?;key = ls.SigningKey(secret)
registry = ls.KeyRegistry()
registry.enroll_record(ls.KeyRecord.from_verifying_bytes("planner", key.verifying_key, "agent"))
principal = registry.verify(envelope)secret is a 32-byte Ed25519 seed that only the signer holds, and envelope is a decoded AgentEnvelope. verify returns the principal that signed the envelope and fails for an unsigned envelope, an unknown key, or a revoked key. verify_at (TypeScript: verifyAt) also checks the key's validity window at a time in epoch microseconds and returns a VerifiedPrincipal with the principal and its kind. verify_observed_at (TypeScript: verifyObservedAt) also checks the observed agdx.ct and agdx.av headers against the signed context.
The managed key registry stores records in the key-value namespace agent.keys by default, keyed by the lowercase hex of the first 8 SHA-256 bytes of the verifying key. Enrollment and revocation use compare-and-swap.
More than agent messages
These data interfaces use standard authenticated Iggy transport. Filtered group readers also open dedicated coordinator and partition connections.
| Surface | Operations | Source of truth |
|---|---|---|
| Streaming | Publish, consume, typed envelopes, replay, batching | Apache Iggy log |
| Consumer filters | Group-aware reads, fenced acknowledgments, sample tests, previews, group filter policies and their revisions | Apache Iggy log, with the policy catalog in the managed plane |
| Materialized views | Projections, schemas, query, change feed, graph traversal | Views derived from log records |
| Working state | Key-value, compare-and-swap, fenced writes, leases, copy-on-write forks | Ordered mutations recorded through the platform |
Memory combines these interfaces instead of adding a wire command family. remember publishes a typed record. Recall reads an available view, graph operations manage relationships, and context supplies conversation scope.
Capability negotiation
The SDK sends hello when it connects and again when it refreshes capabilities. The reply reports managed support, operation versions, feature bits, backends, and optionally the names of the ops stream topics: control, dead letters, changes, and the managed mutation topics. Explicit client configuration wins over the reported names.
Backend descriptors report resource identity, generation, readiness, and supported operations. A backend that is replaying does not enable managed operations. After startup or a restart, refresh capabilities or wait for readiness with a deadline. See Managed data.
- An unavailable interface returns a typed unsupported error.
- A mismatched operation version fails locally before sending.
- Optional guarantees, such as stronger query consistency, need explicit advertised support.
- The
sessionsflag is set only by a server that serves the managed session reads. A client starts with it off and never infers it from the SDK version. The formeragent_workflowbit is retired and never set, and the retired run codes are never reused. - The
stream_tenancyflag means the deployment scopes every managed name to one stream. - Features default to off. A server must not advertise a guarantee it cannot provide.
- Consumer filters report native evaluation, group-aware reads, and the policy catalog separately. The LaserData Iggy server evaluates filters and serves group reads without the managed plane. Configuring a group's filter needs the catalog in the managed plane. Without it, a group with no catalog history reads unfiltered, and a group with catalog history, or a read that needs a minimum catalog position, returns
catalog_unavailable. - Standalone Iggy supports streaming and agent services backed by the log. Managed operations need advertised capabilities, which Laser Stack and LaserData Cloud supply.
Use laser.capabilities() to choose supported operations. Applications do not need to guess support from failed requests or supply a capability list by hand.
Interop is an edge mapping
Bridges translate public protocols into AGDX at the system boundary. Internal agents keep reading and appending durable records. A request passes through the external adapter, an AGDX command, the internal agent, an AGDX reply or stream, and the response adapter.
| External contract | AGDX mapping |
|---|---|
| A2A message and task lifecycle | Command on a fresh task conversation, then response, error, and task-status records. submit_in runs the task as a child session |
MCP tools/call | Command carrying the tool name, and a correlated response or error. call_tool_in runs the call as a child session |
| AG-UI chat, reasoning, and tool calls | Chunk streams rendered as frontend events |
| AG-UI shared state | state_snapshot and RFC 6902 state_delta events |
| Human approval | Ordinary command and response through request_input and respond_input |
Map shared meaning into envelope fields and keep protocol-specific body bytes unchanged. The bridge_hops metadata key is the loop guard. enter_bridge(bridge, previous) (TypeScript: enterBridge) appends a bridge ID to a hop list and rejects an ID that is already present. In Rust it is laser_sdk::a2a::enter_bridge with the a2a-bridge feature and laser_sdk::mcp::enter_bridge with the mcp-bridge feature. Bridges in all three SDKs stamp the list on the records they publish, and with_bridge_hops(previous) (TypeScript: withBridgeHops) continues an existing path. See Interop.
Encoding and conformance
Every client must keep these rules:
- Encode wire payloads through one named-field CBOR encoder.
- Encode exactly one CBOR item. Reject trailing bytes, corrupt known fields, and wrong known types.
- Ignore unknown fields for additive changes. Keep unknown dictionary codes without rejecting the whole record.
- Omit absent optional fields instead of writing empty placeholders.
- Encode envelope IDs as 16-byte big-endian values. Header IDs ride as canonical Crockford strings, and readers also accept the older typed
Uint128conversation header. - Apply the message-kind rules before publishing and after decoding.
- Keep accepted bytes and rejected shapes from the fixtures. Decoding, validating, and encoding again must produce identical bytes.
The laser-wire crate defines the contract and the golden fixtures. It contains no network transport runtime, no envelope signing or verification, no clock, and no ID minting. Schema fingerprinting and content hashing are part of the wire crate. All clients use the same envelope rules and scenarios.