Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
---
name: release
description: Cut a new release - create release branch, bump version in package.json, refresh root and demo lockfiles, regenerate CHANGELOG, and stage for review. Usage: /release patch|minor|major or /release <exact-version>.
description: Cut a new release - create release branch, bump version in package.json and src/version.ts, refresh root and demo lockfiles, regenerate CHANGELOG, and stage for review. Usage: /release patch|minor|major or /release <exact-version>.
allowed-tools: Bash(git checkout *), Bash(git branch *), Bash(git status *), Bash(git diff *), Bash(git add *), Bash(git log *), Bash(npm install *), Bash(npm run *), Bash(rm *), Bash(ls *), Read, Edit, Glob, Grep, Skill, AskUserQuestion
---

# Release: Cut a New Release Branch

Create a `release/NEW_VERSION` branch, bump the version in `package.json`,
refresh lockfiles, regenerate the `CHANGELOG.md` entry for the new version,
and stage everything for review. Do **not** commit — committing and pushing
Create a `release/NEW_VERSION` branch, bump the version in `package.json`
and `src/version.ts`, refresh lockfiles, regenerate the `CHANGELOG.md`
entry for the new version, and stage everything for review. Do **not** commit — committing and pushing
remain human actions per `CLAUDE.md` workflow rules.

## Step 1: Pre-flight checks
Expand Down Expand Up @@ -68,10 +68,16 @@ git checkout -b release/NEW_VERSION
If the branch already exists, stop and ask the user to delete it or pick a
different version — never `--force` over an existing branch.

## Step 5: Bump the version in package.json
## Step 5: Bump the version in package.json and src/version.ts

Use **Edit** to change the `version` field only. Do NOT use `npm version`
— it creates a tag and commit, and we want the human to control both.
Use **Edit** to change the `version` field in `package.json` only. Do NOT
use `npm version` — it creates a tag and commit, and we want the human to
control both.

Then use **Edit** to update the `VERSION` constant in `src/version.ts` to
the same `NEW_VERSION` string. This file is the source of the
`ai-transport-js` agent identifier value sent to Ably for SDK usage
tracking — it must stay in lockstep with `package.json`.

## Step 6: Refresh the root lockfile

Expand Down Expand Up @@ -121,8 +127,9 @@ If the changelog skill reports no PRs found (placeholder `-` bullet),
note this in the final summary and remind the user to fill it in.

**Contract after this step:** `CHANGELOG.md` is modified but not staged.
The working tree also contains the `package.json` edit from Step 5 and
the lockfile changes from Steps 6-7, all unstaged. Step 10 stages them.
The working tree also contains the `package.json` and `src/version.ts`
edits from Step 5 and the lockfile changes from Steps 6-7, all unstaged.
Step 10 stages them.

## Step 9: Validate

Expand All @@ -137,7 +144,7 @@ Run `npm run precommit` (format:check + lint + typecheck).

Stage the files this skill modified:

1. Always stage: `git add package.json package-lock.json CHANGELOG.md`
1. Always stage: `git add package.json package-lock.json src/version.ts CHANGELOG.md`
2. For each demo app directory recorded in Step 7, stage its lockfile
explicitly using its path — for example
`git add demo/vercel/react/use-chat/package-lock.json`. Do **not**
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,7 @@ export async function POST(req: Request) {
const data = (await req.json()) as InvocationData<UIMessageChunk, UIMessage>;
const invocation = Invocation.fromJSON(data);

const channel = ably.channels.get(invocation.sessionName);
const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName: invocation.sessionName });
await session.connect();
const run = session.createRun(invocation, { signal: req.signal });

Expand Down Expand Up @@ -237,7 +236,8 @@ import { createClientSession } from '@ably/ai-transport';
import { myCodec } from './my-codec';

const session = createClientSession({
channel, // Ably RealtimeChannel
client: ably, // Ably.Realtime
channelName: 'ai:demo',
codec: myCodec,
clientId: 'user-123',
api: '/api/chat',
Expand All @@ -261,7 +261,7 @@ while (true) {
import { createAgentSession, Invocation } from '@ably/ai-transport';
import { myCodec } from './my-codec';

const session = createAgentSession({ channel, codec: myCodec });
const session = createAgentSession({ client: ably, channelName: 'ai:demo', codec: myCodec });
await session.connect();
const run = session.createRun(invocation);

Expand Down
3 changes: 1 addition & 2 deletions demo/vercel/react/use-chat/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY! });
export async function POST(req: Request) {
const data = (await req.json()) as InvocationData<UIMessageChunk, UIMessage>;
const invocation = Invocation.fromJSON(data);
const channel = ably.channels.get(invocation.sessionName);

const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName: invocation.sessionName });
await session.connect();
const run = session.createRun(invocation, { signal: req.signal });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ export async function POST(req: Request) {
const { toolApprovals } = data;
const invocation = Invocation.fromJSON(data);

const channel = ably.channels.get(invocation.sessionName);
const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName: invocation.sessionName });
await session.connect();
const run = session.createRun(invocation, { signal: req.signal });

Expand Down
5 changes: 2 additions & 3 deletions docs/concepts/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ import { streamText } from 'ai';
import { Invocation } from '@ably/ai-transport';
import { createAgentSession } from '@ably/ai-transport/vercel';

const channel = ably.channels.get(channelName);
const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName });
await session.connect();

const run = session.createRun(Invocation.fromJSON({ runId, clientId }));
Expand All @@ -69,7 +68,7 @@ The client session manages conversation state: the message list, conversation tr
```typescript
import { createClientSession } from '@ably/ai-transport/vercel';

const session = createClientSession({ channel, clientId });
const session = createClientSession({ client: ably, channelName, clientId });
await session.connect();
const view = session.view;

Expand Down
2 changes: 1 addition & 1 deletion docs/features/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { streamText } from 'ai';
import { Invocation } from '@ably/ai-transport';
import { createAgentSession } from '@ably/ai-transport/vercel';

const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName });
await session.connect();
const run = session.createRun(Invocation.fromJSON({ runId, clientId }));

Expand Down
2 changes: 1 addition & 1 deletion docs/features/tool-calling.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { z } from 'zod';
import { Invocation } from '@ably/ai-transport';
import { createAgentSession } from '@ably/ai-transport/vercel';

const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName });
await session.connect();
const run = session.createRun(Invocation.fromJSON({ runId, clientId }));

Expand Down
2 changes: 1 addition & 1 deletion docs/frameworks/vercel-ai-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ import { Invocation } from '@ably/ai-transport';
import { createAgentSession } from '@ably/ai-transport/vercel';
import { streamText, convertToModelMessages } from 'ai';

const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName });
await session.connect();
const run = session.createRun(Invocation.fromJSON({ runId, clientId, parent, forkOf }));

Expand Down
10 changes: 5 additions & 5 deletions docs/get-started/vercel-use-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,8 @@ const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY! });

export async function POST(req: Request) {
const { messages, history, chatId, runId, clientId, forkOf, parent } = (await req.json()) as ChatRequestBody;
const channel = ably.channels.get(chatId);

const session = createAgentSession({ channel });
const session = createAgentSession({ client: ably, channelName: chatId });
await session.connect();
const run = session.createRun(Invocation.fromJSON({ runId, clientId, parent, forkOf }));

Expand Down Expand Up @@ -149,7 +148,7 @@ The `after()` call is a Next.js API that runs work after the HTTP response is se

## 4. Create the chat component

Wire up `useChat()` with the AI Transport hooks. `ChatTransportProvider` creates both the `ClientSession` and `ChatTransport` and wraps children with Ably's `ChannelProvider` internally:
Wire up `useChat()` with the AI Transport hooks. `ChatTransportProvider` creates both the `ClientSession` and `ChatTransport`. The Ably Realtime client is read from the surrounding `<AblyProvider>`; the session is bound to the supplied `channelName`.

```typescript
// app/chat.tsx
Expand Down Expand Up @@ -205,8 +204,9 @@ function ChatInner({ chatId }: { chatId: string }) {

export function Chat({ chatId, clientId }: { chatId: string; clientId?: string }) {
return (
// ChatTransportProvider creates both ClientSession and ChatTransport,
// and wraps children with ChannelProvider. No codec argument needed.
// ChatTransportProvider creates both ClientSession and ChatTransport.
// The Realtime client is read from the surrounding <AblyProvider>; the
// session resolves the channel from channelName itself. No codec argument needed.
<ChatTransportProvider channelName={chatId} clientId={clientId}>
<ChatInner chatId={chatId} />
</ChatTransportProvider>
Expand Down
7 changes: 4 additions & 3 deletions docs/get-started/vercel-use-client-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Same as the [useChat quickstart](vercel-use-chat.md#prerequisites). Follow steps

## Create the chat component

Instead of `useChat()`, compose the generic hooks directly. `ClientSessionProvider` creates the session and wraps children with Ably's `ChannelProvider` internally:
Instead of `useChat()`, compose the generic hooks directly. `ClientSessionProvider` creates the session — it reads the Realtime client from the surrounding `<AblyProvider>` and binds the session to the supplied `channelName`.

```typescript
// app/chat.tsx
Expand Down Expand Up @@ -104,8 +104,9 @@ function ChatInner({ chatId }: { chatId: string }) {

export function Chat({ chatId, clientId }: { chatId: string; clientId?: string }) {
return (
// ClientSessionProvider creates the ClientSession, wraps children with ChannelProvider,
// and merges `body` into every HTTP POST so the server knows which channel to use.
// ClientSessionProvider creates the ClientSession (reading the Realtime
// client from the surrounding <AblyProvider>) and merges `body` into every
// HTTP POST so the server knows which channel to use.
<ClientSessionProvider
channelName={chatId}
codec={UIMessageCodec}
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/react-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Import from `@ably/ai-transport/react`.

### ClientSessionProvider

Create a `ClientSession` and make it available to descendant components. Wraps children with Ably's `ChannelProvider` internally.
Create a `ClientSession` and make it available to descendant components. The Realtime client is read from the surrounding `<AblyProvider>`; the session is bound to the supplied `channelName`.

```tsx
<ClientSessionProvider
Expand Down
8 changes: 5 additions & 3 deletions examples/custom-codec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,14 @@ import { createAgentSession, createClientSession } from '@ably/ai-transport';
import { AgentCodec } from './codec.js';

// Agent side
const agentSession = createAgentSession(channel, { codec: AgentCodec });
const agentSession = createAgentSession({ client: ably, channelName, codec: AgentCodec });

// Client side
const clientSession = createClientSession(channel, {
const clientSession = createClientSession({
client: ably,
channelName,
codec: AgentCodec,
sendUrl: '/api/chat',
api: '/api/chat',
});
```

Expand Down
2 changes: 1 addition & 1 deletion examples/custom-codec/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ class AgentAccumulator implements MessageAccumulator<AgentEvent, AgentMessage> {
* import { createAgentSession } from '@ably/ai-transport';
* import { AgentCodec } from './codec.js';
*
* const session = createAgentSession(channel, { codec: AgentCodec });
* const session = createAgentSession({ client: ably, channelName, codec: AgentCodec });
* ```
*/
export const AgentCodec: Codec<AgentEvent, AgentMessage> = {
Expand Down
2 changes: 1 addition & 1 deletion specification
41 changes: 41 additions & 0 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
Comment thread
lawrence-forooghian marked this conversation as resolved.
* Wraps the two paths chat-js uses (see ChatClient._addAgent): the
* `options.agents` mutation (read by ably-js when opening the initial
* WebSocket) and the `params.agent` channel option (sent on ATTACH so
* an already-open connection still carries the identifier).
*
* `options.agents` is a private API on the Realtime client — no public
* typed accessor exists in the `ably` package — so this module casts to a
* `RealtimeWithOptions` shape to write it.
*/

import type * as Ably from 'ably';

import { VERSION } from '../version.js';

interface RealtimeWithOptions extends Ably.Realtime {
options: { agents?: Record<string, string | undefined> };
}

const SDK_NAME = 'ai-transport-js';

/**
* Register this SDK on the supplied Realtime client and return the channel
* options the caller should pass to `client.channels.get(...)` so the agent
* is also carried on channel ATTACH. Sets
* `options.agents['ai-transport-js'] = VERSION`. Idempotent — repeated
* calls with the same client produce the same key/value.
* @param client - The Ably Realtime client to register on.
* @returns Channel options containing `params.agent` for `channels.get`.
*/
export const registerAgent = (client: Ably.Realtime): { params: { agent: string } } => {
// CAST: Ably.Realtime's public type omits `options.agents`, but the SDK
// does carry it at runtime. ably-chat-js relies on the same shape — see
// ChatClient._addAgent in https://github.com/ably/ably-chat-js.
const realtime = client as RealtimeWithOptions;
realtime.options.agents = {
...realtime.options.agents,
[SDK_NAME]: VERSION,
};
return { params: { agent: `${SDK_NAME}/${VERSION}` } };
};
11 changes: 9 additions & 2 deletions src/core/transport/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { ErrorCode } from '../../errors.js';
import type { Logger } from '../../logger.js';
import { getHeaders, mergeHeaders } from '../../utils.js';
import { registerAgent } from '../agent.js';
import { buildTransportHeaders } from './headers.js';
import { Invocation } from './invocation.js';
import { pipeStream } from './pipe-stream.js';
Expand Down Expand Up @@ -93,7 +94,11 @@ class DefaultAgentSession<TEvent, TMessage> implements AgentSession<TEvent, TMes
private readonly _onChannelStateChange: Ably.channelEventCallback;

constructor(options: AgentSessionOptions<TEvent, TMessage>) {
this._channel = options.channel;
// Spec: AIT-ST1a, AIT-ST1a2 — register this SDK on both the connection
// (options.agents) and channel-attach (params.agent) paths. Idempotent
// across sessions sharing one client.
const channelOptions = registerAgent(options.client);
this._channel = options.client.channels.get(options.channelName, channelOptions);
this._codec = options.codec;
this._logger = options.logger?.withContext({ component: 'AgentSession' });
this._onError = options.onError;
Expand Down Expand Up @@ -638,7 +643,9 @@ class DefaultAgentSession<TEvent, TMessage> implements AgentSession<TEvent, TMes
// ---------------------------------------------------------------------------

/**
* Create an agent (server-side) session bound to the given channel and codec.
* Create an agent (server-side) session bound to the given Realtime client
* and channel name. The caller owns the client's lifecycle; the session
* owns its channel.
* @param options - Session configuration.
* @returns A new {@link AgentSession} instance.
*/
Expand Down
13 changes: 10 additions & 3 deletions src/core/transport/client-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { EventEmitter } from '../../event-emitter.js';
import type { Logger } from '../../logger.js';
import { LogLevel, makeLogger } from '../../logger.js';
import { getHeaders } from '../../utils.js';
import { registerAgent } from '../agent.js';
import type { DecoderOutput, MessageAccumulator, StreamDecoder } from '../codec/types.js';
import { buildTransportHeaders } from './headers.js';
import type { StreamRouter } from './stream-router.js';
Expand Down Expand Up @@ -146,7 +147,11 @@ class DefaultClientSession<TEvent, TMessage> implements ClientSession<TEvent, TM
private _pendingLocalEvents: EventsNode<TEvent>[] = [];

constructor(options: ClientSessionOptions<TEvent, TMessage>) {
this._channel = options.channel;
// Spec: AIT-CT1a, AIT-CT1a2 — register this SDK on both the connection
// (options.agents) and channel-attach (params.agent) paths. Idempotent
// across sessions sharing one client.
const channelOptions = registerAgent(options.client);
this._channel = options.client.channels.get(options.channelName, channelOptions);
this._codec = options.codec;
this._clientId = options.clientId;
this._api = options.api;
Expand Down Expand Up @@ -1012,8 +1017,10 @@ class DefaultClientSession<TEvent, TMessage> implements ClientSession<TEvent, TM
/**
* Create a client-side session that manages conversation state over an Ably channel.
*
* The session is created in a not-yet-connected state. Callers must `await session.connect()`
* before `send`, `regenerate`, `edit`, `update`, `cancel`, or `waitForRun`.
* The caller owns the client's lifecycle; the session owns its channel.
* The session is created in a not-yet-connected state — callers must
* `await session.connect()` before `send`, `regenerate`, `edit`, `update`,
* `cancel`, or `waitForRun`.
* @param options - Configuration for the client session.
* @returns A new {@link ClientSession} instance.
*/
Expand Down
26 changes: 22 additions & 4 deletions src/core/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,16 @@ export interface CancelRequest {

/** Options for creating an agent session. */
export interface AgentSessionOptions<TEvent, TMessage> {
/** The Ably channel to publish to. Must match the client's channel. */
channel: Ably.RealtimeChannel;
/**
* The Ably Realtime client. The caller owns its lifecycle —
* `session.close()` does not close the client.
*/
client: Ably.Realtime;
/**
* The name of the channel to publish to. The session owns this channel —
* do not also resolve it elsewhere with conflicting channel options.
*/
channelName: string;
/** The codec to use for encoding events and messages. */
codec: Codec<TEvent, TMessage>;
/** Logger instance for diagnostic output. */
Expand Down Expand Up @@ -290,8 +298,18 @@ export interface AgentSession<TEvent, TMessage> {

/** Options for creating a client session. */
export interface ClientSessionOptions<TEvent, TMessage> {
/** The Ably channel to receive responses on and publish cancel signals to. */
channel: Ably.RealtimeChannel;
/**
* The Ably Realtime client. The caller owns its lifecycle —
* `session.close()` does not close the client.
*/
client: Ably.Realtime;

/**
* The name of the channel to subscribe to and publish cancel signals on.
* The session owns this channel — do not also resolve it elsewhere with
* conflicting channel options.
*/
channelName: string;

/** The codec to use for encoding/decoding. */
codec: Codec<TEvent, TMessage>;
Expand Down
Loading
Loading