Skip to content
Helix Docs

Agent sessions

How an agent remembers a conversation: project and user scoped sessions, the model's message window, retention, and reading sessions from code.

For developers Updated Oct 20, 2026
View as Markdown

A session is one conversation with an agent: every message exchanged, in order, plus any tool calls waiting for approval. The runtime creates one on the first call, returns its id, and loads it again when you pass the id back. Sessions come in two scopes. A project session is shared by every execution in the project; a user session belongs to the signed-in user who started it and is invisible to anyone else.

Starting and continuing

Omit sessionId and the call creates a session. Pass one back and the agent sees the earlier turns:

const first = await ctx.agent('support-agent').generate({ prompt: 'Where is order 1042?' });
first.sessionId; // 'ses_…'

const second = await ctx.agent('support-agent').generate({
  prompt: 'And when will it arrive?',
  sessionId: first.sessionId,
});

You may also choose the id yourself. An id the store has never seen starts a new session under that id, so a client can generate one up front and use it from the first message. Each call appends the new turn and everything the model produced, including tool calls and tool results.

Two scopes

memory.scope on the agent decides who a session belongs to.

scope: 'project' (default)scope: 'user'
Who can continue a sessionAny execution in the project that has the idOnly executions invoked by the same signed-in user
Same id from a different userContinues the same conversationNever the other user’s conversation. See below
From a scheduleWorksThrows: schedules have no user
agent.sessions() listingNot supportedLists the calling user’s sessions

Use the project scope for back-office agents, schedules, and anything where the conversation is about the project rather than a person. Use the user scope for anything a signed-in person talks to. The runtime keys user sessions by the userId on the execution’s ctx, so a user who presents another user’s session id never reaches that person’s history. Under helix dev and with your own session table, the id addresses a separate, empty session of the caller’s own. On the platform store the call is refused as not found, which is the same answer a session that does not exist gets. See the ctx object for how userId is set.

// functions/_agents/assistant.ts
import { defineAgent } from '@trayai/helix-sdk';

export default defineAgent({
  name: 'assistant',
  description: 'Personal assistant for the signed-in user.',
  model: 'claude-sonnet-4-5',
  auth: 'anthropicKey',
  instructions: 'Help the user with their own account.',
  memory: { scope: 'user' },
});

Locally, helix agent chat <name> --user <id> sets the user for a chat with a user-scoped agent. Without it the dev server rejects the call.

Listing a user’s sessions

For a user-scoped agent, sessions() returns the calling user’s conversations, most recently active first, with a cursor for the next page:

// functions/api/v1/conversations.get.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  cursor: z.string().optional(),
});

export default defineFunction<typeof input>(async (ctx) => {
  const page = await ctx.agent('assistant').sessions({ first: 20, cursor: ctx.input.cursor });
  return page;
  // {
  //   sessions: [{ id, createdAt, lastActivityAt, messageCount, pendingApprovals }, …],
  //   nextCursor: string | null,
  // }
});

There is no way to list another user’s sessions, and no listing at all for project-scoped agents.

What is stored and what the model sees

A session stores the whole conversation. Nothing is removed as it grows, so session.messages() can always return every message the user and the agent exchanged, including tool calls and their results. That is what lets someone open a conversation from months ago and read all of it.

What the model is sent is a separate, smaller thing. Each turn the runtime sends the most recent memory.maxSessionMessages messages, 200 by default. The model reads its whole input on every step, so without a cap a long conversation gets slower and more expensive with every turn. The window is chosen so the model always receives a valid history:

  • It starts on a user message. If the cap would cut into the middle of a turn, the window starts at the next turn instead, so it never opens with an assistant reply or a tool result whose question is missing.
  • A tool call is never separated from its result. Model providers reject a history where one appears without the other. A single turn longer than the cap, such as one with many tool steps, is sent whole rather than shortened.
  • A pending approval is never left out. The turn waiting on a person is always in the window, however small the cap.

Set the cap on the agent:

memory: { maxSessionMessages: 60 },

A lower cap means cheaper calls and an agent that recalls less of a long conversation. It does not change what is stored. Tools that return large results make every message in the window bigger; see defining tools on keeping results small.

Retention

Sessions are kept until you clear them. There is no expiry by default, so a user can return to an earlier conversation and pick it up where it stopped.

To have idle sessions removed, set memory.ttl, in seconds:

memory: { ttl: 60 * 60 * 24 * 30 }, // remove after 30 days without activity

With a ttl, a session untouched for that long expires. Each turn the agent writes, and each approval resolved, resets the clock. Continuing an expired session with no approvals pending starts a fresh one under the same id. Continuing an expired session that still had approvals pending throws, with a message that names the session and says it expired, so a late approval never silently lands in a new conversation.

Pending approvals have no clock of their own. An approval waits for as long as its session is kept, so without a ttl it never lapses, and with one it lapses only when the session does.

Reading a session

agent.session(id) opens the session without running the agent. Use it to show history or check for pending approvals after a stream:

const session = ctx.agent('support-agent').session(ctx.input.sessionId);

const history = await session.messages();             // every message, oldest first
const recent = await session.messages({ limit: 10 }); // the last 10
const pending = await session.pendingApprovals();     // approvals waiting on a person

messages() returns AI SDK ModelMessage values: role and content, where content is a string or an array of parts (text, tool calls, tool results, approval requests). Rendering them in a browser is covered in building a chat UI for an agent.

Paging through a long conversation

messages() loads the whole conversation, which is fine for most and wasteful for a very long one. page() reads it a page at a time. Each call returns the messages and a nextCursor; pass the cursor back to get the next page, and stop when it is null:

// functions/api/v1/conversations/messages.get.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  sessionId: z.string().min(1),
  cursor: z.string().optional(),
});

export default defineFunction<typeof input>(async (ctx) => {
  const session = ctx.agent('assistant').session(ctx.input.sessionId);

  // Newest first: the first call returns the latest 50 messages, and each
  // cursor reaches further back, which is how a chat view loads on scroll.
  const page = await session.page({ limit: 50, order: 'desc', cursor: ctx.input.cursor });

  return { messages: page.messages, nextCursor: page.nextCursor };
});
OptionDefaultMeaning
limit50Messages per page, from 1 to 500
order'asc''asc' pages oldest to newest; 'desc' starts at the latest message and pages back
cursornoneThe nextCursor of the previous page. Keep the same order across pages

Pages never overlap. A cursor is opaque: store it and pass it back, but do not build or parse one.

Deleting a session

clear() permanently deletes a session: every message and any pending approval. It returns once everything is gone. Afterwards the same id starts empty.

Use it to give users control of their own history:

// functions/api/v1/conversations/[sessionId].delete.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({ sessionId: z.string().min(1) });

export default defineFunction<typeof input>(async (ctx) => {
  await ctx.agent('assistant').session(ctx.input.sessionId).clear();
  return { deleted: true };
});

For a user-scoped agent, a session id only ever resolves to the calling user’s own conversation, so this endpoint cannot delete anyone else’s. Together with sessions() and page(), it is everything a conversation list needs: list, open, delete.

Use it also when your own code knows a conversation is finished. An automated agent that runs off a trigger usually has no reason to keep its history once the run is done:

// functions/_scheduled/nightly-triage.ts
import { defineSchedule } from '@trayai/helix-sdk';

export default defineSchedule({
  cron: '0 2 * * *',
  timezone: 'Europe/London',
  handler: async (ctx) => {
    const agent = ctx.agent('triage-agent');
    const result = await agent.generate({ prompt: 'Triage the tickets opened today.' });

    ctx.log.info('Triage finished', { cost: result.helix.cost.amount });

    // The run is complete and nothing will continue this conversation.
    await agent.session(result.sessionId).clear();
  },
});

Do not clear a session that is waiting on an approval unless you mean to abandon it: the pending approval is deleted with it, and the paused run cannot be resumed.

Where sessions live

helix devDeployed
StoreIn memory, inside the dev serverManaged by the platform
LifetimeLost on every restartUntil clear(), or the retention described above
SetupNoneThe project must be provisioned
Scope and listingEmulated, including user scope and sessions()Enforced by the platform from the execution’s identity

The code is the same in both places. If your own table is a requirement, or you want to manage history yourself, see bring your own session storage.

Full interface: ctx.agent() reference.

Loading search…

Jump to a section

tab to move · esc to close