INTEGRATIONS

Talk to a profile or workgroup from your own code: a scoped device token over the host-plane WebSocket, host.chat.send frames, workgroup methods, and a Node example.

13 / 16·reference·v0.14.4

How to talk to a profile or a workgroup from your own code — a Node service, a Java backend, a CI step, anything that can open a WebSocket.

Audience: a developer wiring an external project to an alpi daemon running on another machine. For the human chat surfaces see the apps; for agent-to-agent links see ALP.md.

The model

There is no public HTTP API and there is no cloud middleman. Your code becomes a host-plane client of a specific daemon — the same role the mobile app plays. It dials the daemon over a WebSocket on a private network or through a TLS reverse proxy, authenticates with one device credential under a connection, and calls the same host.* JSON-RPC methods the apps use.

┌──────────────────────┐                  ┌───────────────────────────┐
│ machine A            │                  │ machine B                 │
│                      │   ws:// + token  │                           │
│  your node/java app  │───Tailscale/LAN─▶│  alpi daemon              │
│  (host-plane client) │   host.chat.send │    ├─ profile "abby"      │
│                      │◀──frames─────────│    └─ workgroup "casa-ops" │
└──────────────────────┘                  └───────────────────────────┘

Which route

Host-plane connection (this doc)ALP peer (ALP.md)
Use whenboth machines share a trusted private net (Tailscale/LAN)the network is untrusted, or you want a first-class peer identity
TransportWebSocket + JSON-RPCNoise_XK over TCP, signed envelopes
Authbearer device token, profile-scopedEd25519 keypair pinned in peers.yaml
Integration costa WebSocket + JSON — minutesembed an ALP client (handshake + signing)
Can @mention / be a workgroup peer identityno (acts as a local member)yes

For "a script on the private net wants to ask a profile", the device-token route is the right one. The rest of this doc covers it.

Use ws:// only with a Tailscale/private IP literal over a trusted network; hostname routes require wss://. For Internet access, expose a reverse proxy on wss:// and keep the daemon's plaintext listener private. Desktop and mobile validate the proxy certificate and reject invalid TLS.

Step 1 — turn on the listener (machine B)

The daemon always serves a local Unix socket; the remote WebSocket listener is what your code dials. Relevant ~/.alpi/config.yaml keys:

network:
  host: ""                 # advertised address; empty = auto-detect Tailscale, then LAN

host:
  tcp_port: 49200          # default
  allow_public_bind: false # keep false — public IPs are rejected unless this is true
  device_name: ""          # pairing label; defaults to the hostname
  endpoints:
    - url: wss://your.domain.com
      label: Secure Internet
    - url: ws://100.64.10.2:49200
      label: Direct

By default the listener binds to the machine's Tailscale address if one is detected, otherwise the LAN address. A public IP is refused unless you explicitly set host.allow_public_bind: true (don't, for an integration).

Configure routes in alpi setupConnectionsNetwork. Their order is preserved and the first route is the default encoded in a pairing code. For the supplied Docker/Caddy topology, follow docker/README.md: the proxy must be reachable and its certificate valid before a client can dial the advertised route.

Step 2 — create a scoped connection

Run alpi setupConnectionsNew connection on machine B:

  1. Label it (e.g. ci-bot).
  2. Choose member (not admin) — admin can manage profiles and devices; an integration never needs that.
  3. Restrict it to the profile(s) it may reach (e.g. abby). Blank means all profiles — avoid that for an integration.

You get a QR code and an alpi://device?url=…&name=…&pairing_token=… link. The pairing grant expires after ten minutes and can be exchanged once. A normal Desktop/Mobile client does that automatically. A headless integration sends host.connections.exchange_pairing as its first unauthenticated WebSocket message with pairing_token, client, name and app_version, then stores the returned device token before any probe or other fallible setup, then uses it as params.auth_token. Add another device from the connection detail when another client should share the same sessions and accounting; each exchange returns a separate token so either device can be revoked without affecting the other.

A member token restricted to abby can only reach abby, is blocked from every admin method, and gets profile-scoped filtering on responses and events.

Revoke at any time from alpi setupConnections → select the connection → select the device. Admin integrations can use host.connections.revoke_device(connection_id, device_id). A member credential cannot manage connections.

Step 3 — the wire protocol

One WebSocket message is one JSON object. Every request carries auth_token in params. There are two ids and they should match: the top-level JSON-RPC id (the daemon echoes it on every frame, so you can demux) and params.request_id (the turn identity used by cancel and backfill). request_id must be a non-empty value.

host.chat.send is a streaming method — it answers with a sequence of frames, not one reply. There is no non-streaming chat method.

Request:

{ "id": 1, "method": "host.chat.send",
  "params": { "auth_token": "<TOKEN>", "profile": "abby",
              "text": "status of the deploy?", "request_id": 1,
              "session_id": "<optional, to continue a conversation>" } }

For file input, stage the file first with host.attachments.stage and pass the returned metadata as params.attachments (a list).

Frames (each is { "id": 1, "event": "...", ... }):

eventFieldsMeaning
session_startsession_id, model_usedfirst frame; capture session_id to continue later
reasoning_deltatextextended-reasoning fragment
assistant_deltatexta chunk of the answer — concatenate
tool_start / tool_state / tool_endtool_id, name, …the agent ran a tool
auto_compacttext, tokens_before, tokens_aftercontext was compacted
heartbeatkeep-alive (every 5s); ignore
replytext, session_id, attachments?the final answer text
donesession_idterminal — stream ends here

Errors: a JSON-RPC error ({ "id":1, "error": { "code", "message" } }) means the request was rejected (e.g. -32000 auth-failed, -32001 forbidden for an out-of-scope profile). A frame with event: "error" (carrying text) means the turn failed mid-stream; it arrives before done.

To continue a conversation, pass the session_id you got back as params.session_id on the next host.chat.send.

Step 4 — talk to a profile (Node)

import WebSocket from 'ws'; // npm i ws

let _id = 0;
const nextId = () => ++_id; // never 0 — an empty request_id is rejected

export function chat(endpoint, { profile, text, sessionId, onDelta }) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}`);
    const id = nextId();
    let reply = '';
    let session = sessionId || null;

    ws.on('open', () => ws.send(JSON.stringify({
      id,
      method: 'host.chat.send',
      params: {
        auth_token: endpoint.token,
        profile,
        text,
        request_id: id,
        ...(sessionId ? { session_id: sessionId } : {}),
      },
    })));

    ws.on('message', (raw) => {
      let f;
      try { f = JSON.parse(raw.toString()); } catch { return; }
      if (f.id !== id) return;
      if (f.error) { ws.close(); return reject(new Error(`${f.error.code} ${f.error.message}`)); }
      switch (f.event) {
        case 'session_start': session = f.session_id; break;
        case 'assistant_delta': reply += f.text; onDelta?.(f.text); break;
        case 'reply': reply = f.text; session = f.session_id; break;
        case 'error': ws.close(); return reject(new Error(f.text || 'stream error'));
        case 'done': ws.close(); return resolve({ text: reply, sessionId: session });
      }
    });

    ws.on('error', reject);
  });
}

const endpoint = { ip: '100.64.50.234', port: 49200, token: process.env.ALPI_TOKEN };

const first = await chat(endpoint, { profile: 'abby', text: 'Did the nightly deploy pass?' });
console.log(first.text);

// continue the same conversation
const next = await chat(endpoint, {
  profile: 'abby', sessionId: first.sessionId, text: 'And the migration step?',
});
console.log(next.text);

Surviving a dropped connection

If the WebSocket dies mid-turn the daemon keeps running the turn and records every frame to a sidecar. Replay with host.chat.events_since (non-streaming): pass { profile, session_id, after_seq } and it returns { events, next_seq, exists, in_flight }. Track next_seq as your cursor and poll while in_flight is true. For a fire-and-forget integration you rarely need this; the apps use it for reconnect resilience.

Step 5 — talk to a workgroup (Node)

A workgroup is a shared, encrypted transcript anchored at a hub. Your token acts as a local member profile: the daemon holds that profile's keys and does the crypto for you. A member token can list, read, post, and read task state; creating or administering a workgroup is admin-only.

// one-shot unary RPC (no request_id needed for non-chat methods)
function call(endpoint, method, params) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}`);
    const id = nextId();
    ws.on('open', () => ws.send(JSON.stringify({
      id, method, params: { auth_token: endpoint.token, ...params },
    })));
    ws.on('message', (raw) => {
      let b; try { b = JSON.parse(raw.toString()); } catch { return; }
      if (b.id !== id) return;
      ws.close();
      if (b.error) return reject(new Error(`${b.error.code} ${b.error.message}`));
      resolve(b.result);
    });
    ws.on('error', reject);
  });
}

// what workgroups is this profile in?
const { workgroups } = await call(endpoint, 'host.workgroups.list', { profile: 'abby' });

// post a message
await call(endpoint, 'host.workgroup.post', {
  profile: 'abby', wg_id: 'casa-ops', text: 'CI: build 412 green ✅',
});

// read new posts incrementally
let after = 0;
const { posts, next_seq } = await call(endpoint, 'host.workgroup.transcript', {
  profile: 'abby', wg_id: 'casa-ops', after_seq: after,
});
after = next_seq;
for (const p of posts) console.log(`#${p.seq} ${p.from}: ${p.body}`);

// fold task state (active / closed / blocked)
const tasks = await call(endpoint, 'host.workgroup.tasks', { profile: 'abby', wg_id: 'casa-ops' });

Member-callable workgroup methods (all take profile, scope-checked):

MethodParamsReturns
host.workgroups.listprofile?{ workgroups: [{ id, profile, name, members, is_hub, hub_id, … }] }
host.workgroup.postprofile, wg_id, text{ ok, seq }
host.workgroup.transcriptprofile, wg_id, after_seq?, limit?, tail?{ posts: [{ seq, at, from, body, cost }], next_seq, limit }
host.workgroup.tasksprofile, wg_id{ active, closed, blocked }

host.workgroup.create / update / add_member / kick / remove / action are admin-only — out of reach for a member token.

Security

Stability

host.* is the same surface the bundled apps use, and alpi takes clean breaks over compatibility shims (see ARCHITECTURE.md). Pin your client to a known daemon version and re-check this contract when you upgrade — method names, params and frame shapes can change between releases.

See also

theme