Skip to content

Call an A11 service from a browser

Connect a TypeScript page to a Python echo service over HTTP/2 Server-Sent Events (SSE). Both peers use the same action schema and named streams, with no browser-specific request format.

Before you start

Install the Python package and npm install a11@npm:@curiositystack/a11. Browsers negotiate HTTP/2 through TLS. Browsers do not support clear-text HTTP/2 prior knowledge (h2c), so local browser testing also requires a trusted development certificate.

Try it

Send a message and inspect what crosses the wire. The inspector records action messages and node fragments; select a row to see its action names, node IDs, and encoded byte size. Half-close says that the client will send no more data while allowing already-sent work to drain. Reconnect creates a fresh transport and session.

1. Define the action contract

Both peers must agree on this schema. The Python service declares:

ECHO_SCHEMA = a11.ActionSchema(
    name="echo",
    description="Return the supplied text unchanged.",
    inputs={"input": a11.ActionPortSchema(
        name="input", type="text/plain", typeinfo=str, required=True
    )},
    outputs={"output": a11.ActionPortSchema(
        name="output", type="text/plain", typeinfo=str, required=True
    )},
)

The browser creates the equivalent ActionSchema and ActionPortSchema, describing the same runnable action and streaming ports on each side.

const echoSchema = new ActionSchema({
    name: 'echo',
    inputs: {
        input: new ActionPortSchema({
            name: 'input', type: 'text/plain', required: true,
        }),
    },
    outputs: {
        output: new ActionPortSchema({
            name: 'output', type: 'text/plain', required: true,
        }),
    },
});

2. Implement the server-only handler

Only the server registers a handler. Inputs and outputs are AsyncNodes, so the handler reads the final input value and puts the same value into the output node:

async def echo(action: a11.Action) -> None:
    value = await action["input"].consume(str)
    await action["output"].put(value, final=True)

The browser registers the schema without a handler. Calling it therefore creates an action message for the remote session instead of executing code in the page.

3. Prepare and run the Python service

Each SSE connection becomes an accepting Session with echo registered as a handler. The endpoint pair shares the /demos/echo prefix:

registry = a11.ActionRegistry()
registry.register("echo", ECHO_SCHEMA, echo)


async def accept(stream):
    session = a11.Session(action_registry=registry)
    await session.add_stream(stream, mode="accept")
    await session.done.wait()


options = a11.HttpSseOptions()
options.connect_endpoint = "/demos/echo"
server = a11.HttpSseServer.create("127.0.0.1", 80, accept, options)

The complete deployable module is a11/demos/echo_server.py. Create and trust a localhost certificate with mkcert, then run the service with TLS:

mkcert localhost 127.0.0.1 ::1
python -m a11.demos.echo_server \
  --host 127.0.0.1 --port 9000 \
  --certificate ./localhost+2.pem \
  --private-key ./localhost+2-key.pem

4. Create a browser session and connect

Create the client registry and session, then attach an SSE stream in START mode. The server attaches the other end in ACCEPT mode. The stream is created against an origin, with the two endpoint paths given relative to it:

const registry = new ActionRegistry();
need(registry.register('echo', echoSchema));
const session = need(Session.create({actionRegistry: registry}));
const stream = need(HttpSseClientWireStream.create('https://a11.to', {
    connectEndpoint: '/sse/demoserver',
    messageEndpoint: '/streams/{id}/message',
}));
need(await session.addStream(stream, StreamMode.START));

5. Wire the interface through AsyncNodes

Make the action against the session's shared node map and stream. call() sends the action description; writing the input and marking it final sends its node fragments. The response arrives through the output AsyncNode:

const action = need(registry.makeAction('echo', {
    nodeMap: session.getNodeMap(), stream, session,
}));
need(await action.call());
const input = need(await action.getInput('input'));
need(await input.finalize(text));
need(await action.waitForDispatch(10_000));
const output = need(await action.getOutput('output', false));
const reply = need(await output.next({timeoutMs: 10_000}));
need(await action.wait(30_000));

The interface field remains an A11 node. Action and session identity persists on both peers, so the page uses the same streaming and completion operations as the service.

6. Display failures

A11 APIs return a StatusOr<T>: success values pass isOk, while failures carry a status code, message, and structured details. Convert failures at the UI boundary and render them in a live error region:

const need = <T>(value: T | Status): T => {
    if (!isOk(value)) {
        throw new Error(`${StatusCode[value.code]}: ${value.message}`);
    }
    return value as T;
};

try {
    await sendEcho(text);
} catch (error) {
    errorRegion.textContent = error instanceof Error
        ? error.message
        : String(error);
}