LaserData Cloud
Laser SDKAdvanced

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

LayerOwnsCan be used alone
SubstrateDurable partitions, offsets, retention, consumer groups, pull-based readsLaser SDK uses Apache Iggy
WirePortable types, named-field CBOR, dictionaries, limits, capability shapes, fixturesYes, by an independent port
PlatformPublish and consume, projections and query, key-value, forks, graphYes, with no agent concepts
FabricAgent envelopes, sessions, reliable consumers, coordination, context, memory, governanceYes, with no public edge protocol
EdgesA2A, MCP, and AG-UI mappingsOnly 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:

HeaderMeaning
agdx.ctContent type of the body
agdx.avEnvelope version
gen_ai.conversation.idThe conversation, which is also the partition key
agdx.parent_conv and agdx.root_convParent and root of a child session
gen_ai.agent.idThe author
agdx.toThe 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 familyFieldsPurpose
Identity and routingkind, record, conversation, parent, root, source, targetWhat this is, which session and session tree it belongs to, who claims to have sent it, and who it is for
Causalitycause, cause_at, correlationParent record, optional native log position, request and reply pairing
Chunk lifecyclechannel, sequence, last, finish_reasonOrdered streaming and deterministic reassembly
Executionoperation, tool, task_state, deadline_micros, idempotency_keyWhat is happening and which safety rules apply
Contentbody, content type headerOpaque payload bytes and their codec
Accounting and policyusage, metadataAdvisory token usage and cost, and pinned policy or routing context
Evolution and integritymust_understand, signatureStrict 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

LimitValue
operation, tool, finish_reason, each256 bytes
Idempotency key64 bytes
Metadata entries, key, value, total32 entries, 256 bytes, 1,024 bytes, 8,192 bytes
Body reference1,024 bytes
Agent card capabilities64
Chunk body64 KiB

Pinned metadata keys

KeyMeaning
roleChat role, such as user, assistant, system, or tool
bridge_hopsThe bridge loop guard, a list of bridge IDs
submittedMarks the first command of a submitted session
gen_ai.request.model, gen_ai.response.model, gen_ai.provider.nameThe requested model, the model that answered, and the provider
duration_microsDuration of a model or tool call, measured by the application
on_behalf_ofThe user an agent acts for
purpose, data_classification, task_context, session_intentPolicy inputs. Advisory unless the envelope is signed

The message kind decides which fields are required and which are forbidden. The wire decoder, the SDK constructors, and receivers enforce the same rules.

KindRequired coreMeaning
commandrecord, conversation, source, correlation, bodyRequests a reply or an effect. A command without a correlation is invalid
responserecord, conversation, source, correlation, bodyAnswers the command with the same correlation
eventrecord, conversation, source, bodyAnnounces something and expects no reply
chunkconversation, source, correlation, channel, sequence, body (the terminal chunk can be empty)Carries one ordered part of chat, reasoning, or tool_args
statusrecord, conversation, source, operationCarries task, session, card, progress, quarantine, or unquarantine state
errorrecord, conversation, source, correlation, bodyEnds a request, and optionally a chunk channel, with a typed error

R means required, O optional, and X forbidden:

Fieldcommandresponseeventchunkstatuserror
correlationRRORO (R for task)R
channel, sequenceXXXRXO (stream terminal)
lastXXXOOX
finish_reasonXOXO (with last)XX
idempotency_keyOOOXXX
deadline_microsOXXO (opening chunk)XX
task_stateXOXXR (task or session)O
operationOOOR on the opening chunk, X afterRO
toolOOOOXO
usageXOOO (terminal chunk)OO

More rules:

  • A status with operation task needs a correlation and a task state.
  • A status with operation session needs a task state and a CBOR body: a start, a transition, or an end. Its last must 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 purpose chat, reasoning, or tool_args. Later chunks must not.
  • root needs parent, 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:

RefinementRustTypeScriptPython keyword
Cause record and positionwith_cause(record, Some(position))withCause(record, position)cause= and cause_at=ls.LogPosition(stream_id, topic_id, partition_id, offset)
Parent and root sessionwith_ancestry(..)withAncestry(..)parent= and root=
Deadline in epoch microsecondswith_deadline_micros(n)withDeadlineMicros(n)deadline_micros=
Idempotency keywith_idempotency_key(key)withIdempotencyKey(key)idempotency_key=
Metadata entrywith_metadata(key, value)withMetadata(key, value)metadata= with a dict
Tool namewith_tool(name)withTool(name)tool=
Token usagewith_usage(usage)withUsage(usage)usage=
Finish reasonwith_finish_reason(reason)withFinishReason(reason)finish_reason=
Target agentwith_target(agent)withTarget(agent)target=
Operation namewith_operation(name)withOperation(name)operation= on command, respond, emit, and fail. Positional on status
Content typecontent_type(type)contentType(type)content_type=
Claim checkclaim_check(&store, threshold)claimCheck(store, threshold)claim_check=(store, threshold_bytes)
Signingsigned_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:

RecordTopicMeaning
status with operation session and task state Submittedagent.sessionsA session handed to an agent, with its start body
command with metadata submitted = true, operation invoke_agent by defaultagent.sessionsThe work handed to the agent in a submission
status with operation session and task state Workingagent.sessionsA start, or a pickup or resume with a transition body
status with operation session and task state Pausedagent.sessionsA participant acknowledging a pause request
status with operation session and a terminal task stateagent.sessionsThe end, with a reason and an optional structured error
command with chat, text_completion, or generate_contentagent.sessionsA model request, answered by a response or error with the same correlation
command with execute_toolagent.sessionsA tool call, answered the same way
event with state_delta or state_snapshotagent.sessionsA JSON Patch delta or a whole-document snapshot of session state
event with context_assembled, context_compacted, or context_retrievedagent.sessionsWhat a model call received, what a summary replaced, and what memory was recalled
event with policy_decisionagent.sessionsA governance decision
status with operation progressagent.heartbeatsOne process heartbeat per stream, listing the sessions it holds
command with session_pause, session_resume, session_cancel, or force_cancelagent.controlAn operator request. The same request on any other topic is never applied
status with operation session, task state Canceled, and reason forcedagent.controlAn 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: Submitted to submitted, Working, InputRequired, and AuthRequired to active, Paused to paused, Completed to completed, Canceled to canceled, and Failed and Rejected to failed.
  • 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

NeedRustTypeScriptPython
Agent ID as textagent.as_str()agent.asStr()A plain str
Agent ID for an envelopeagent.wire_id()agent.wireId()A plain str
Fresh ULID-valued wire IDCorrelationId::mint() with MintUlidMintUlid.mint(CorrelationId)ls.mint_ulid()
Well-known topicAgentTopic::SessionsAgentTopic.Sessionsls.AgentTopic.Sessions
Any other topicAgentTopic::Custom(&identifier) with an Iggy IdentifierAgentTopic.Custom(name)The topic name as a str
Invalid IDIdErrorIdErrorIdError
Invalid provenance headersProvenanceErrorProvenanceErrorProvenanceError

The well-known topics and their names:

TopicNameHolds
Sessionsagent.sessionsThe session lane: work, replies, model and tool records, lifecycle, state, and context
Streamsagent.streamsChunk streams
Heartbeatsagent.heartbeatsProcess heartbeats, with a one-hour expiry
Controlagent.controlOperator control requests, writable by operators only
Memoryagent.memoryMemory records
Auditagent.auditPolicy evidence
Registryagent.registryAgent cards and registry facts
WorkflowJournalagent.workflow_journalWorkflow step outcomes
Dlqagent.dlqDead-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.sessions for 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.dlq include 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.

SignalSafe reading
source, target, usage, cost, policy metadataAdvisory on a shared unsigned topic
Verified envelope signatureThe enrolled principal signed the canonical envelope
Signature contextAlso binds the out-of-band content type and envelope version
Write-exclusive Iggy topic with ACLsAuthorship established by topology
Server-stamped user on managed commandsTrusted input to capability RBAC
Fence token checked by the state storeRejects 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.

SurfaceOperationsSource of truth
StreamingPublish, consume, typed envelopes, replay, batchingApache Iggy log
Consumer filtersGroup-aware reads, fenced acknowledgments, sample tests, previews, group filter policies and their revisionsApache Iggy log, with the policy catalog in the managed plane
Materialized viewsProjections, schemas, query, change feed, graph traversalViews derived from log records
Working stateKey-value, compare-and-swap, fenced writes, leases, copy-on-write forksOrdered 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 sessions flag 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 former agent_workflow bit is retired and never set, and the retired run codes are never reused.
  • The stream_tenancy flag 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 contractAGDX mapping
A2A message and task lifecycleCommand on a fresh task conversation, then response, error, and task-status records. submit_in runs the task as a child session
MCP tools/callCommand 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 callsChunk streams rendered as frontend events
AG-UI shared statestate_snapshot and RFC 6902 state_delta events
Human approvalOrdinary 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 Uint128 conversation 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.

On this page