Skip to content

From a local run to a remote call

On the previous page the model interaction ran in your process: .run() starts an action's handler locally. But interact_with_llm is just an action, and any action can instead run on another peer — a server that holds the API keys, has network egress, or owns a GPU — which you reach with .call() over a session. The action's schema, handler, and ports do not change; only where the handler runs and how you dispatch it do.

To keep every moving part visible we use a tiny action here; a real interact_with_llm makes the identical jump (it just has more ports).

import a11

An action, run locally

Here is a one-line action and a local .run() — feed its input port, read its output port, exactly like the LLM interaction:

async def shout(action):
    text = await action["text"].consume()
    await action["result"].put_final(text.upper())
    await action["result"].drain_and_close()   # seal the output with OK


SHOUT = a11.ActionSchema(
    name="shout",
    inputs={"text": a11.ActionPortSchema(
        name="text", type="text/plain", typeinfo=str, required=True)},
    outputs={"result": a11.ActionPortSchema(
        name="result", type="text/plain", typeinfo=str, required=True)},
)


async def run_locally() -> str:
    action = a11.Action(SHOUT).bind_handler(shout).run()
    async with action["text"] as text:
        await text.put_final("hello")
    return await action["result"].consume()      # -> "HELLO"

Register it so a server can host it

To run the handler on a peer, put it in an ActionRegistry under its name. A server Session built with that registry dispatches incoming calls to it:

registry = a11.ActionRegistry()
registry.register("shout", SHOUT, shout)

Stand up the server

This is the echo server's shape from before, but the session carries an action_registry instead of a message callback — so it answers action calls rather than echoing:

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


options = a11.WebSocketServerOptions()
options.path = "/ws"
server = a11.WebSocketWireServer.create(accept, options)

Call it from a client

The client opens a session over a wire stream, then builds the action with registry.make_action, binding it to that stream and session. make_action needs the schema (to shape the ports) — the handler stays on the server. await action.call() dispatches it across the wire:

client = a11.Session()
stream = a11.WebSocketWireStream.connect(f"ws://127.0.0.1:{server.port}/ws")
await client.add_stream(stream, mode="start")

action = registry.make_action(
    "shout", node_map=client.node_map, stream=stream, session=client
)
await action.call()

Feed and read, unchanged

Once dispatched, the ports behave exactly as in the local case — write the input, read the output — but the bytes now travel over the network to the server's handler and back:

await action["text"].put_final("hello")           # mark the input final...
await action["text"].drain_and_close()            # ...and seal it
print(await action["result"].consume())           # -> "HELLO"

Putting it together

Do the setup — registering the handler, creating the sessions — inside your async entrypoint (under a running event loop), then run the action both ways:

import asyncio
import a11


async def shout(action):
    text = await action["text"].consume()
    await action["result"].put_final(text.upper())
    await action["result"].drain_and_close()   # seal the output with OK


SHOUT = a11.ActionSchema(
    name="shout",
    inputs={"text": a11.ActionPortSchema(
        name="text", type="text/plain", typeinfo=str, required=True)},
    outputs={"result": a11.ActionPortSchema(
        name="result", type="text/plain", typeinfo=str, required=True)},
)


async def main() -> None:
    # Local: the handler runs in this process.
    local = a11.Action(SHOUT).bind_handler(shout).run()
    async with local["text"] as text:
        await text.put_final("hello")
    print("local:", await local["result"].consume())      # -> HELLO

    # Remote: host the same action on a server and call it over a socket.
    registry = a11.ActionRegistry()
    registry.register("shout", SHOUT, shout)

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

    options = a11.WebSocketServerOptions()
    options.path = "/ws"
    server = a11.WebSocketWireServer.create(accept, options)
    try:
        client = a11.Session()
        stream = a11.WebSocketWireStream.connect(
            f"ws://127.0.0.1:{server.port}/ws"
        )
        await client.add_stream(stream, mode="start")

        action = registry.make_action(
            "shout", node_map=client.node_map, stream=stream, session=client
        )
        await action.call()
        await action["text"].put_final("hello")
        await action["text"].drain_and_close()
        print("remote:", await action["result"].consume())  # -> HELLO
        await action.wait()
    finally:
        server.stop()


asyncio.run(main())

The whole point

The two paths differ only in the last mile:

Local Remote
a11.Action(SHOUT).bind_handler(shout) registry.make_action("shout", stream=…, session=…)
.run() — handler runs here .call() — handler runs on the peer

The handler, schema, and port I/O are identical. That is why interact_with_llm moves server-side with no change to how you feed interactions/config/tools or read text_output/new_interactions — set its provider/model/key headers on the action you make_action, and .call() it. Next, let the model call an action of yours back.