Quickstart
Start a server, install the SDK, and have one agent answer another
This page gets two agents talking. One agent handles a request, a caller sends it one, and the caller prints the reply. Then the same exchange runs inside a recorded session. Pick Rust, Python, or TypeScript in the tabs. Your choice carries across the SDK docs.
1. Start a target
Every step on this page works on any of these targets:
- Laser Stack runs Apache Iggy and the managed plane on your machine. Use it for local development.
- LaserData Cloud gives you a connection string on the Credentials tab of your deployment. Make sure an access rule allows your client IP.
- Plain Apache Iggy runs messaging, agents, and sessions. Managed features such as queries and key-value state return
Unsupported.
To start Laser Stack:
git clone https://github.com/laserdata/laser-stack
cd laser-stack
./scripts/upThe script waits for Iggy and the plane, then prints a LASER_CONNECTION_STRING export. Copy it into your shell. For Cloud or your own Iggy server, export that server's connection string the same way:
export LASER_CONNECTION_STRING='iggy:[email protected]:8090'Laser Stack covers requirements, persistence, and troubleshooting. Connect covers connection strings, tokens, and TLS.
2. Install the SDK
npm install @laserdata/laser-sdkRequires Node.js 22.14 or later.
cargo add laser-sdk --features agent
cargo add tokio --features macros,rt-multi-threadRequires Rust 1.98.0 or later. The agent feature adds agents and sessions. Add managed for queries, key-value state, and the other managed features.
pip install laser-sdkRequires Python 3.10 or later. With uv, run uv pip install laser-sdk instead.
3. Have one agent answer another
The program connects, creates the agent topics on a stream named quickstart, and starts an agent called triage. Then a caller called intake sends triage a request and prints the reply.
import {
Agent,
AgentId,
AgentTopic,
Laser,
TopicRetention,
agentMessageBody,
routeTo
} from "@laserdata/laser-sdk"
const text = new TextEncoder()
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
await using root = await Laser.connectEnv()
const laser = root.withDefaultStream("quickstart")
await laser.bootstrap(1, TopicRetention.expireAfter(86_400_000))
await using triage = Agent.builder()
.id(AgentId.new("triage"))
.listenOn(AgentTopic.Sessions)
.respondOn(AgentTopic.Sessions)
.handler({
handle: (message, ctx) =>
ctx.respond(text.encode(`triaged: ${decode(agentMessageBody(message))}`))
})
.build()
.spawn(laser)
await triage.ready()
const outcome = await laser
.contract(routeTo(AgentId.new("triage")))
.from(AgentId.new("intake"))
.payload(text.encode("disk full on node-7"))
.inboxRoute({ kind: "fixed", topic: AgentTopic.Sessions })
.send()
if (outcome.kind === "completed") {
console.log(decode(agentMessageBody(outcome.reply)))
} else {
console.log(`no reply: ${outcome.kind}`)
}use laser_sdk::prelude::full::*;
use std::time::Duration;
struct Triage;
impl AgentHandler for Triage {
async fn handle(&self, message: &AgentMessage, ctx: &AgentCtx<'_>) -> Result<(), LaserError> {
let ticket = String::from_utf8_lossy(message.body());
ctx.respond(format!("triaged: {ticket}")).await
}
}
#[tokio::main]
async fn main() -> Result<(), LaserError> {
let laser = Laser::connect_env().await?.with_default_stream("quickstart");
let retention = TopicRetention::expire_after(Duration::from_secs(86_400));
laser.bootstrap(1, retention).await?;
let mut triage = Agent::builder()
.id("triage".parse()?)
.listen_on(AgentTopic::Sessions)
.respond_on(AgentTopic::Sessions)
.handler(Triage)
.build()
.spawn(laser.clone());
triage.ready().await?;
let outcome = laser
.contract(Router::to("triage".parse()?))
.from("intake".parse()?)
.payload("disk full on node-7")
.inbox_route(InboxRoute::Fixed(AgentTopic::Sessions))
.send()
.await?;
match outcome {
Contract::Completed(reply) => println!("{}", String::from_utf8_lossy(reply.body())),
other => println!("no reply: {other:?}"),
}
triage.shutdown().await
}import asyncio
import laser_sdk as ls
async def handle(ctx, message):
ticket = bytes(message.body()).decode()
await ctx.respond(f"triaged: {ticket}".encode())
async def main():
async with await ls.Laser.connect_env() as root:
laser = root.with_default_stream("quickstart")
await laser.bootstrap(1, ls.TopicRetention.expire_after(86_400_000))
async with laser.spawn_agent(
"triage",
ls.AgentTopic.Sessions,
handle,
respond_on=ls.AgentTopic.Sessions,
):
outcome = await laser.contract(
None,
b"disk full on node-7",
agent="triage",
source="intake",
fixed_inbox=ls.AgentTopic.Sessions,
)
if isinstance(outcome, ls.Contract.Completed):
print(bytes(outcome[0].body()).decode())
else:
print(f"no reply: {outcome!r}")
asyncio.run(main())The program prints:
triaged: disk full on node-7What each part does:
bootstrapcreates the agent topics on the stream, one partition each, and keepsagent.sessionsrecords for one day. It is safe to run again.ready()waits until the agent reads its topic, so the request cannot arrive before it listens. Python'sasync withwaits for you.contractsends one request to one agent and waits for the answer, 30 seconds by default. It ends as completed, failed, not consumed, or timed out.- The fixed inbox sends the request to
agent.sessions, wheretriagelistens. Without it, the SDK looks up the agent's inbox from live presence, which needs an agent that advertises capabilities and a server that serves presence.
4. Record the exchange as a session
A session is one unit of work with a start, an end, and every record in between, kept on the log. Open a session as intake, send the request inside it, end the session, and read it back. Replace the request in step 3 with this code.
import { sessionTurnText } from "@laserdata/laser-sdk"
const { session } = await laser.sessions().start().agent(AgentId.new("intake")).begin()
await laser
.contract(routeTo(AgentId.new("triage")))
.from(AgentId.new("intake"))
.payload(text.encode("disk full on node-7"))
.inboxRoute({ kind: "fixed", topic: AgentTopic.Sessions })
.conversation(session.conversation)
.send()
await session.end()
for (const turn of await session.context()) {
console.log(turn.display, turn.display === "agent.message" ? sessionTurnText(turn) : "")
}let (session, _lease) = laser
.sessions()
.start()
.agent("intake".parse::<AgentId>()?)
.begin()
.await?;
laser
.contract(Router::to("triage".parse()?))
.from("intake".parse()?)
.payload("disk full on node-7")
.inbox_route(InboxRoute::Fixed(AgentTopic::Sessions))
.conversation(session.conversation())
.send()
.await?;
session.end().await?;
for turn in session.context().await? {
let kind: &str = turn.display.into();
match kind {
"agent.message" => println!("{kind}: {}", turn.text()),
_ => println!("{kind}"),
}
}session, _lease = await laser.sessions().start().agent("intake").begin()
await laser.contract(
None,
b"disk full on node-7",
agent="triage",
source="intake",
fixed_inbox=ls.AgentTopic.Sessions,
conversation=session.conversation,
)
await session.end()
for turn in await session.context():
print(turn.display, turn.text() if turn.display == "agent.message" else "")The program prints the session as it was recorded:
session.started
agent.message disk full on node-7
agent.message triaged: disk full on node-7
session.completedThe request and the reply carry the session ID as their conversation, so context() returns them with the session's start and end. This works on plain Apache Iggy, because the SDK reads the records back from agent.sessions. Laser Stack and LaserData Cloud also index sessions, which adds listing, status, and timeline reads across the stream. Sessions shows both.
5. Publish and read your own messages
Agents use ordinary topics underneath. To publish your own records, such as telemetry or support tickets, and read them back by offset, follow Messages.
Next
Agents
Discover agents by capability, set deadlines, and stop agents cleanly
Sessions
Record model calls, tool calls, and state for one unit of work
Concepts
Streams, topics, partitions, agents, and sessions in one page
Laser Stack
Run Iggy and the managed plane on your machine
Connect
Connection strings, tokens, and TLS