Skip to content

Getting started ​

This page installs the contracts, checks a message with a schema, and connects a client and a host over a port. With @adecore/agents on the host side and @adecore/agents-react in the window, you write neither end of the port. The last sections show what they do, for an app that writes one end itself.

Install ​

sh
bun add @adecore/agent-contracts
sh
npm install @adecore/agent-contracts
sh
pnpm add @adecore/agent-contracts

Zod 4 comes along as a dependency. There is no CSS and no words to load, and the package runs in a browser, a preload, Node and Bun.

Check a message ​

A schema parses what crosses a boundary and throws on what does not fit. The inferred types describe the parsed output.

ts
import { AGENT_REQUEST_SCHEMAS, ChatAttachmentUploadSchema } from '@adecore/agent-contracts';
import type { ChatSendPayload } from '@adecore/agent-contracts/chat';

const message: ChatSendPayload = {
    chatId: 'chat-1',
    text: 'Read the attached note.',
    attachments: [ChatAttachmentUploadSchema.parse({ name: 'note.txt', mime: 'text/plain', data: 'SGVsbG8=' })]
};

const payload = AGENT_REQUEST_SCHEMAS['chat.send'].payload.parse(message);
const result = AGENT_REQUEST_SCHEMAS['chat.send'].result.parse({ queued: false });

.parse() throws a ZodError. .safeParse() returns { success, data } or { success, error }. A parse strips unknown keys. Few fields have a default, so an omitted optional field stays absent and the reader decides what that means. The guides say so where it matters, such as an account without enabled, which is on.

AGENT_REQUEST_SCHEMAS holds a payload and a result schema per request, and AGENT_EVENT_SCHEMAS one schema per event. Requests and events lists them all.

Implement a port ​

A FramePort is the connection between a client and a host. It sends a frame and hands every frame that arrives to a listener:

ts
interface FramePort {
    send(frame: Request | ServerFrame): void;
    onFrame(listener: (frame: unknown) => void): () => void;
}

A MessagePort of the DOM fits in a few lines. Both ends of a MessageChannel wrap the same way:

ts
import type { FramePort } from '@adecore/agent-contracts';

export function framePortOf(port: MessagePort): FramePort {
    return {
        send: (frame) => port.postMessage(frame),
        onFrame: (listener) => {
            const receive = (event: MessageEvent): void => listener(event.data);
            port.addEventListener('message', receive);
            port.start();

            return () => port.removeEventListener('message', receive);
        }
    };
}

A port that clones its messages passes frames as they are. Over a socket, send JSON.stringify(frame) and hand the listener the parsed text. Electron's MessagePortMain in the main or a utility process calls its listener with { data } through port.on('message', ...), so read data there. The port knows nothing of connecting, closing or timeouts; the code that makes it decides that.

The client side ​

A client sends a request with an id of its own and waits for the reply with that id. It checks the frame with parseServerFrame and the result with the request's schema:

ts
import { AGENT_EVENT_SCHEMAS, AGENT_REQUEST_SCHEMAS, parseServerFrame, type AgentRequestType, type FramePort } from '@adecore/agent-contracts';

type Result<T extends AgentRequestType> = ReturnType<(typeof AGENT_REQUEST_SCHEMAS)[T]['result']['parse']>;

export function connect(port: FramePort) {
    const waiting = new Map<string, { type: AgentRequestType; resolve: (result: unknown) => void; reject: (error: Error) => void }>();
    let nextId = 0;

    port.onFrame((input) => {
        const frame = parseServerFrame(input);

        if (!frame.ok) {
            return;
        }

        if ('event' in frame.value) {
            if (frame.value.event === 'chat.status') {
                const { chatId, info } = AGENT_EVENT_SCHEMAS['chat.status'].parse(frame.value.payload);
                console.log(chatId, info.status);
            }

            return;
        }

        const entry = frame.value.id === null ? undefined : waiting.get(frame.value.id);

        if (entry === undefined || frame.value.id === null) {
            return;
        }

        waiting.delete(frame.value.id);

        if (frame.value.ok) {
            entry.resolve(AGENT_REQUEST_SCHEMAS[entry.type].result.parse(frame.value.result));
        } else {
            entry.reject(new Error(`${frame.value.error.code}: ${frame.value.error.message}`));
        }
    });

    return <T extends AgentRequestType>(type: T, payload: unknown): Promise<Result<T>> =>
        new Promise((resolve, reject) => {
            const id = String(++nextId);
            waiting.set(id, { type, resolve: resolve as (result: unknown) => void, reject });
            port.send({ id, type, payload: AGENT_REQUEST_SCHEMAS[type].payload.parse(payload) });
        });
}

const request = connect(framePortOf(channel.port1));
const { chats } = await request('chat.list', {});

channel is a MessageChannel. In an app the two ends usually live in two processes: the window keeps one, and the process that runs the chats gets the other.

portTransport of @adecore/agents-react is this client, with a status, event subscriptions and typed payloads. See Getting started with the views.

The host side ​

A host reads a request with parseRequest, looks up its schemas, checks the payload and answers with the same id. A failure carries a code the client can branch on:

ts
import { AGENT_REQUEST_SCHEMAS, parseRequest, type AgentRequestType, type FramePort } from '@adecore/agent-contracts';

export function serve(port: FramePort): () => void {
    return port.onFrame((input) => {
        const frame = parseRequest(input);

        if (!frame.ok) {
            port.send({ id: null, ok: false, error: { code: 'bad-request', message: frame.message } });

            return;
        }

        const { id, type, payload } = frame.value;

        if (!(type in AGENT_REQUEST_SCHEMAS)) {
            port.send({ id, ok: false, error: { code: 'unknown-request', message: `No request ${type}.` } });

            return;
        }

        const parsed = AGENT_REQUEST_SCHEMAS[type as AgentRequestType].payload.safeParse(payload);

        if (!parsed.success) {
            port.send({ id, ok: false, error: { code: 'bad-request', message: parsed.error.message } });

            return;
        }

        if (type === 'chat.list') {
            port.send({ id, ok: true, result: { chats: [] } });
        } else {
            port.send({ id, ok: false, error: { code: 'chat-unsupported', message: `${type} is not served here.` } });
        }
    });
}

serve(framePortOf(channel.port2));

AgentHost.connect(port) of @adecore/agents is this host, with every request answered. See Getting started with the host.

Checklist ​

  • Both ends wrap their port as a FramePort, and the receiver checks every frame it gets.
  • The host answers every request with its id, also when it fails.
  • A failure carries a code, and the client branches on the code, not on the message.
  • The app decides who may connect before it hands either end a port. The contracts have no authentication.

Licenses and third-party notices are listed in each package.