LaserData Cloud
Laser SDK

Session

Record one agent conversation as typed turns, with model-ready context, scoped memory, and checkpointed replay

A session is one agent conversation, seen as typed turns. A turn is one step of the conversation: an instruction, a model response, a tool call, a tool result, a human decision, or the final reply. The session gives you a model-ready context, memory for that conversation, and replay from a saved checkpoint.

A session is a view over Context. It adds no second store and no new wire operation. Each turn is an ordinary agent message on the log.

Built for

Use sessions for agent runtimes, support assistants, tool-calling loops, human approval steps, and conversation replay.

How it works

laser.sessions() returns a session factory. The call does no I/O. Network work happens only in the verbs, such as append and context. The factory opens a session in one of three ways:

  • create(id) opens the durable session named id. The conversation ID derives from id, so the same id always reaches the same history. Rust, Python, and TypeScript derive the same conversation ID from the same id.
  • start() opens a new session with a random conversation ID. Keep session.conversation if you want to open it again later.
  • open(conversation) opens the session for an existing conversation ID. The ID can come from start(), from the provenance of an inbound message, or from a sub-conversation.

Opening a session creates nothing on the server. The first append writes the first turn.

Turn kinds

append(kind, data) writes one turn. The kind selects the topic. Each kind has its own conversation-level agent topic:

KindTopicUse it for
instructionagent.commandsA user turn or a system directive
responseagent.responsesThe agent's reply to the conversation
model.responseagent.llm_ioA raw model completion
tool.callagent.tool_callsA tool call that the agent makes
tool.resultagent.tool_resultsThe result that a tool returns
human.inputagent.human_inputA human prompt or decision

The kind names are the same in all three SDKs. Rust also has the SessionTurnKind enum, for example SessionTurnKind::ModelResponse. To map between a kind and its default topic, use SessionTurnKind::topic() and SessionTurnKind::for_topic(..) in Rust, Sessions.turn_topic(kind) and Sessions.turn_kind(topic) in Python, and sessionTurnTopic(kind) and sessionTurnKind(topic) in TypeScript.

Each turn carries the conversation ID in its provenance headers. The conversation ID is also the partition key. All turns of one conversation on one topic therefore go to the same partition, in the sequence they were written.

A turn is an ordinary agent message. Any reader of these topics sees it. Any agent message on these topics with the same conversation ID reads back as a turn. For example, a Fabric agent that replies on agent.responses to a message of this conversation keeps its conversation ID. The session shows that reply as a response turn.

If you install an ActionGovernor, it checks each append as a send action before the write.

Reading turns back

A read collects the session's messages from all of its topics and sorts them by the timestamp that Iggy assigns. Each topic has its own offsets, so the timestamp is the only clock shared across topics. If two messages on different topics have the same timestamp, the sort falls back to topic, partition, and offset.

Each read returns turns. A turn holds the kind and the message from the log, with its payload, provenance, and topic:

  • Rust: turn.kind, turn.message, and turn.text() for the payload as UTF-8.
  • Python: turn.kind as a string, turn.message, and turn.text(). Read the payload as turn.message.payload.
  • TypeScript: turn.kind, turn.message, and sessionTurnText(turn) for the payload as UTF-8.

Model-ready context

context() returns the last 50 turns across the session's topics. It then keeps the newest turns that fit in about 4,000 estimated tokens. The estimate is one token for each 4 bytes of payload. If the newest turn alone is larger than the budget, the context still returns that one turn.

To use other bounds for one read:

  • Rust: context_with(policy) takes any context policy, such as Chain of LastN and TokenBudget.
  • TypeScript: contextWith(policy) takes the same policies.
  • Python: context(last_n=, token_budget=) replaces either bound, and context_with(policy) takes LastN, TokenBudget, RoleFilter, Chain, or a synchronous callable.

Rust's TokenBudget::with_estimator(..), the second argument of TypeScript's TokenBudget, and Python's TokenBudget(max_tokens, estimator=) accept your own tokenizer. Context describes the policies.

Memory

memory() returns memory for this conversation in the agent.session namespace. The conversation ID scopes it, so two sessions in one namespace do not see each other's items.

All three SDKs support these operations on session memory:

  • remember(payload) stores an item for this conversation.
  • recall() finds items in this conversation. Rust and TypeScript return a builder. Python takes limit, semantic, strategy, and folded keywords.
  • search(text) runs a keyword recall. It needs no embedder. On the default log memory it returns this conversation's recent items, because log memory does not use the text.
  • block(token_budget) returns recalled items as one prompt-ready string.
  • consolidate(max_items) keeps the newest items and prunes the others. It takes the summarizer options from Memory.

All three SDKs also have forget(id) and improve(..) on session memory.

Default recall, including search, reads the managed key-value view. That view needs Laser Stack or LaserData Cloud. With Iggy alone, use folded recall, which rebuilds memory from the topic in your process: recall().folded() in Rust and TypeScript, and recall(folded=True) or search(.., folded=True) in Python. Memory describes recall strategies and backends.

To use another namespace, call memory_in(namespace) in Rust and Python, or memory(namespace) in TypeScript. Python's memory(memory) also accepts any memory handle from laser.memory, memory_with, memory_on_topic, memory_topic, or VectorMemory.governed.

Shared graph

A knowledge graph stays shared across conversations. session.graph(name) returns the same graph as laser.graph(name) in all three SDKs. Rust needs the graph feature. See Graph.

scope() returns the underlying Context scope. TypeScript exposes it as the scope property. Use it to read a topic outside the session, or to apply your own context policy.

Checkpoints and replay

checkpoint() records where the session's topics end now. For each partition of each topic, it stores the next offset that the partition will write. A checkpoint is a bookmark on the client. It is never a record on the log.

A checkpoint covers whole partitions, not one conversation. Other conversations on the same partitions move the offsets too. The session reads only its own turns on each side of the checkpoint.

A checkpoint serializes, so you can store it anywhere and use it later:

  • Rust: Checkpoint implements serde Serialize and Deserialize.
  • Python: checkpoint.to_json() and Checkpoint.from_json(json).
  • TypeScript: checkpoint.toJSON() (also used by JSON.stringify) and Checkpoint.fromJSON(value).

Four verbs split the history at a checkpoint:

VerbWhat it reads
turns_at(checkpoint)The turns before the checkpoint
turns_since(checkpoint)The turns appended after the checkpoint
state_at(checkpoint, init, fold)Folds the turns before the checkpoint into state, as the state was then
replay(checkpoint, init, fold)Folds the turns after the checkpoint into state, to bring saved state up to date

Context and turn reads use the Context read window. State reconstruction reads the whole requested range. state_at ends at the checkpoint, and replay starts after the checkpoint, so state does not omit intervening turns when the range exceeds 10,000 records.

A fold function takes the current state and one turn, and returns the new state. A common pattern: save your state together with a checkpoint. After a restart, load both and call replay to apply only the newer turns.

TypeScript uses camelCase names: turnsAt, turnsSince, stateAt, and replay.

Custom layout

The default layout uses the six agent topics on the connection's default stream. Change the layout when one fleet of agents must not share topics or partitions with another fleet. You can set these values:

  • The stream for all session topics.
  • The topic for each turn kind.
  • The memory namespace.
  • The turn bound and the token bound for context().

Rust uses laser.sessions_with(SessionConfig::new()...). TypeScript passes a config to laser.sessions(new SessionConfig()...). Both chain .stream(..), .topic(kind, topic), .memoryNamespace(..) (Rust: memory_namespace), .contextTurns(..), and .contextTokens(..). Python passes keyword arguments to laser.sessions(..), with topics as a map from kind to topic name, such as {"instruction": "support.turns"}.

Each kind needs its own topic, because the topic tells a reader which kind a turn is. If two kinds share one topic, the SDK rejects the layout with an invalid error. Rust's sessions_with returns LaserError::Invalid. Python's laser.sessions(..) and TypeScript's laser.sessions(config) raise InvalidError.

sessions.config and session.config return the layout in use. Its getters are stream_name, memory_namespace_name, context_turn_bound, context_token_bound, topics, topic_for(kind), and kind_for(topic) (TypeScript: camelCase, with topics() as a method). Rust has the same getters as methods.

The layout is part of the session's address. The same id on a different layout reads a different history. A session on custom topics does not see turns on the default topics.

Quick example

const encoder = new TextEncoder()
const session = laser.sessions().create("agent-42")
await session.append("instruction", encoder.encode("summarize the ticket"))
await session.append("tool.call", encoder.encode("search(ticket=42)"))
await session.append("tool.result", encoder.encode("3 comments found"))
await session.append("model.response", encoder.encode("it is a login bug"))

const turns = await session.context()
for (const turn of turns) {
  console.log(turn.kind, sessionTurnText(turn))
}

const checkpoint = await session.checkpoint()
const saved = JSON.stringify(checkpoint)

await session.append("response", encoder.encode("it is a login bug"))
const later = await session.turnsSince(Checkpoint.fromJSON(saved))
let session = laser.sessions().create("agent-42");
session
    .append(SessionTurnKind::Instruction, b"summarize the ticket")
    .await?;
session
    .append(SessionTurnKind::ToolCall, b"search(ticket=42)")
    .await?;
session
    .append(SessionTurnKind::ToolResult, b"3 comments found")
    .await?;
session
    .append(SessionTurnKind::ModelResponse, b"it is a login bug")
    .await?;

for turn in session.context().await? {
    println!("{} {}", turn.kind, turn.text());
}

let checkpoint = session.checkpoint().await?;
let saved = serde_json::to_string(&checkpoint)?;

session
    .append(SessionTurnKind::Response, b"it is a login bug")
    .await?;
let restored: Checkpoint = serde_json::from_str(&saved)?;
let later = session.turns_since(restored).await?;
session = laser.sessions().create("agent-42")
await session.append("instruction", "summarize the ticket")
await session.append("tool.call", "search(ticket=42)")
await session.append("tool.result", "3 comments found")
await session.append("model.response", "it is a login bug")

for turn in await session.context():
    print(turn.kind, turn.text())

checkpoint = await session.checkpoint()
saved = checkpoint.to_json()

await session.append("response", "it is a login bug")
later = await session.turns_since(ls.Checkpoint.from_json(saved))

Fold and context example

This example counts tool calls before and after a checkpoint, then reads a tighter context than the default.

const toolCalls = (count: number, turn: SessionTurn) =>
  turn.kind === "tool.call" ? count + 1 : count

const before = await session.stateAt(checkpoint, 0, toolCalls)
const total = await session.replay(checkpoint, before, toolCalls)

const recent = await session.contextWith(
  new Chain([new LastN(5), new TokenBudget(500)])
)
let tool_calls = |count: usize, turn: &SessionTurn| {
    if turn.kind == SessionTurnKind::ToolCall {
        count + 1
    } else {
        count
    }
};

let before = session.state_at(checkpoint.clone(), 0, tool_calls).await?;
let total = session.replay(checkpoint, before, tool_calls).await?;

let recent = session
    .context_with(Box::new(Chain(vec![
        Box::new(LastN(5)),
        Box::new(TokenBudget::new(500)),
    ])))
    .await?;
def tool_calls(count, turn):
    return count + 1 if turn.kind == "tool.call" else count


before = await session.state_at(checkpoint, 0, tool_calls)
total = await session.replay(checkpoint, before, tool_calls)

recent = await session.context_with(ls.Chain([ls.LastN(5), ls.TokenBudget(500)]))

before counts the tool calls up to the checkpoint. total starts from before and adds the calls after it.

Custom layout example

const sessions = laser.sessions(
  new SessionConfig()
    .stream("support")
    .topic("instruction", "support.turns")
    .topic("response", "support.replies")
    .memoryNamespace("support.sessions")
    .contextTurns(10)
    .contextTokens(800)
)
const session = sessions.create("ticket-7")
static TURNS: LazyLock<Identifier> =
    LazyLock::new(|| Identifier::named("support.turns").expect("valid name"));
static REPLIES: LazyLock<Identifier> =
    LazyLock::new(|| Identifier::named("support.replies").expect("valid name"));

let config = SessionConfig::new()
    .stream("support")
    .topic(SessionTurnKind::Instruction, AgentTopic::Custom(&TURNS))
    .topic(SessionTurnKind::Response, AgentTopic::Custom(&REPLIES))
    .memory_namespace("support.sessions")
    .context_turns(10)
    .context_tokens(800);
let session = laser.sessions_with(config)?.create("ticket-7");
sessions = laser.sessions(
    stream="support",
    topics={"instruction": "support.turns", "response": "support.replies"},
    memory_namespace="support.sessions",
    context_turns=10,
    context_tokens=800,
)
session = sessions.create("ticket-7")

Key operations

VerbWhat it does
sessions()The session factory with the default layout
sessions_with(config) / sessions(config) / sessions(kwargs)The session factory with a custom layout (Rust / TypeScript / Python)
SessionConfigThe layout: stream, topic per kind, memory namespace, and context bounds
create(id)The durable session for id, same conversation in every SDK
start()A new session with a random conversation ID
open(conversation)The session for an existing conversation ID
session.conversationThe conversation ID (Rust: conversation())
append(kind, data)Write one typed turn on the topic of its kind
context()The last 50 turns, trimmed to about 4,000 estimated tokens
context_with(policy)The context under your own policy
memory()This conversation's memory in the agent.session namespace
memory_in(namespace)This conversation's memory in another namespace (TypeScript: memory(namespace))
graph(name)The shared knowledge graph
scope()The underlying context scope (TypeScript: the scope property)
checkpoint()Save the next offset of each partition of each session topic
turns_at(checkpoint) / turns_since(checkpoint)The turns before or after a checkpoint
state_at(checkpoint, init, fold) / replay(checkpoint, init, fold)Fold the turns before or after a checkpoint into state

Read limits

Context and turn reads examine at most CONTEXT_READ_WINDOW (10,000) raw records of each partition, then keep the turns of this conversation. For turns_at, the window ends at the checkpoint. State reconstruction through state_at and replay reads the whole requested range.

Many conversations share a partition. Turns older than the newest 10,000 records on their partition are outside the read. For a long conversation, save your state with a checkpoint at regular points. Then replay reads only the turns after the last checkpoint.

Running it

Run bootstrap(partitions) once per stream before the first append. It creates the agent topics on the connection's default stream, including the six session topics. If you use a custom stream or custom topics, create them first, for example with laser.stream(name).ensure() and laser.stream(name).topic(name).ensure(partitions).

In Rust, sessions are part of the agent feature. Python and TypeScript include them.

Turns, context, checkpoints, and replay work with Iggy alone, with Laser Stack, and with LaserData Cloud. Default memory recall needs the managed key-value view on Laser Stack or LaserData Cloud. Folded recall and a vector memory handle work with Iggy alone.

On this page