Skip to main content
gaxam worker
$ ssh operator@gaxam

Authenticate

Monitor workers and review saved results.

session encrypted · argon2 · audit logged

gaxam worker
Overview Dashboard Operations Workers Sessions Questions Access API keys Agent keys Docs SSE guide

Dashboard

Live status of workers, sessions and saved results.

Recent workers

View all

Sessions

View all

Workers

Track every worker run, its state and saved progress.

Worker Session Status Progress Last saved Queued Actions
Workers

Worker

Progress

Connecting to live stream…

0%

Event log

0 events

    Questions

    Saved results for each exam session.

    # Type Question Answer Saved Actions

    Sessions

    Each session holds one cookie jar and its saved results.

    Name Cookies Saved Created Actions

    API keys

    API keys authorize worker and stream operations, limited to the scopes you grant.

    Label Key Scopes Status Created Last used Actions

    Agent keys

    Cookie jars submitted through API keys, who submitted them, and the runs they produced.

    Marker API key Status Cookies Result Created Actions

    SSE guide

    How an external client follows a worker's progress over Server-Sent Events, with example frames for every event type.

    01Create an API key

    The full key looks like gaxam_<lookup>.<secret> and is shown only once. A key without stream:read gets 403 apikey_scope_denied.

    Open API keys and create a key with the stream:read scope. Add worker:read if the client also fetches worker details. Every request sends it as a bearer token:

    Authorization: Bearer gaxam_xxxxxxxx.yyyyyyyyyyyyyyyy

    02Get the worker id

    A stream is scoped to one worker. The worker must belong to the key owner; any other id returns 404 worker_not_found.

    Starting a run with POST /api/v1/agent/credentials/{id}/run (scope worker:run) returns the worker_id. You can also copy it from the Workers page URL, #/workers/<worker_id>.

    201 response, credential object shortened
    {
      "credential": { "id": "3f0c…", "status": "running", … },
      "worker_id": "7c1e2a9d-4b4f-4c8e-9a51-0e6b2f3d8a10",
      "session_id": "b52d7e01-93c4-4f0a-8c2e-5d1f6a7b9c33",
      "message": "Worker queued for this credential"
    }

    03Open the stream

    -N turns off curl buffering so frames print as they arrive. Lines starting with : are heartbeats (every 15 s by default); ignore them.

    Send a GET with the bearer header. The connection stays open and the server pushes frames until the worker finishes.

    curl -N \
      -H "Authorization: Bearer $GAXAM_KEY" \
      -H "Accept: text/event-stream" \
      https://your-host/api/v1/workers/$WORKER_ID/stream

    Every frame has an event: name, a JSON data: line and, for stored events, an id: with the event sequence number.

    04Reconnect without losing events

    Default limits: a stream lives at most 2 hours, then sends stream_timeout. One reconnect replays up to 500 missed events.

    Remember the last id: you processed and send it back as Last-Event-ID. Each new connection starts with a fresh snapshot, then replays everything after that id, then continues live.

    curl -N \
      -H "Authorization: Bearer $GAXAM_KEY" \
      -H "Last-Event-ID: 42" \
      https://your-host/api/v1/workers/$WORKER_ID/stream

    Stop reconnecting after a terminal event. A 4xx response (bad key, missing scope, unknown worker) will not fix itself on retry.

    05Client examples

    The browser EventSource API cannot send an Authorization header, so call the stream from your backend. Both examples parse frames by hand and need no SSE library.
    Node.js 18+ (built-in fetch)
    const BASE = "https://your-host/api/v1";
    const KEY = process.env.GAXAM_KEY;
    const WORKER = process.argv[2];
    let lastId = "";
    
    async function follow() {
      const headers = { Authorization: `Bearer ${KEY}`, Accept: "text/event-stream" };
      if (lastId) headers["Last-Event-ID"] = lastId;
      const res = await fetch(`${BASE}/workers/${WORKER}/stream`, { headers });
      if (!res.ok) {
        const err = new Error(`${res.status} ${await res.text()}`);
        err.fatal = res.status < 500;
        throw err;
      }
      const decoder = new TextDecoder();
      let buffer = "";
      for await (const chunk of res.body) {
        buffer += decoder.decode(chunk, { stream: true });
        let cut;
        while ((cut = buffer.indexOf("\n\n")) !== -1) {
          const raw = buffer.slice(0, cut);
          buffer = buffer.slice(cut + 2);
          let event = "message", id = null;
          const data = [];
          for (const line of raw.split("\n")) {
            if (line.startsWith(":")) continue; // heartbeat
            const i = line.indexOf(":");
            const field = i === -1 ? line : line.slice(0, i);
            const value = i === -1 ? "" : line.slice(i + 1).replace(/^ /, "");
            if (field === "event") event = value;
            else if (field === "data") data.push(value);
            else if (field === "id") id = value;
          }
          if (!data.length) continue;
          if (id && event !== "snapshot") lastId = id;
          console.log(event, JSON.parse(data.join("\n")));
          if (event === "terminal") return true;
        }
      }
      return false; // closed early: timeout or network drop
    }
    
    for (;;) {
      try {
        if (await follow()) break;
      } catch (err) {
        console.error(err.message);
        if (err.fatal) break;
      }
      await new Promise((r) => setTimeout(r, 3000));
    }
    Python 3 (httpx)
    import json, os, sys, time
    import httpx
    
    BASE = "https://your-host/api/v1"
    KEY = os.environ["GAXAM_KEY"]
    WORKER = sys.argv[1]
    state = {"last_id": None}
    
    def follow():
        headers = {"Authorization": f"Bearer {KEY}", "Accept": "text/event-stream"}
        if state["last_id"]:
            headers["Last-Event-ID"] = state["last_id"]
        timeout = httpx.Timeout(10.0, read=60.0)  # heartbeats arrive every 15 s
        url = f"{BASE}/workers/{WORKER}/stream"
        with httpx.stream("GET", url, headers=headers, timeout=timeout) as res:
            res.raise_for_status()
            event, data, seq = "message", [], None
            for line in res.iter_lines():
                if line == "":
                    if data:
                        if seq and event != "snapshot":
                            state["last_id"] = seq
                        print(event, json.loads("\n".join(data)))
                        if event == "terminal":
                            return True
                    event, data, seq = "message", [], None
                elif line.startswith(":"):
                    continue  # heartbeat
                else:
                    field, _, value = line.partition(":")
                    value = value[1:] if value.startswith(" ") else value
                    if field == "event":
                        event = value
                    elif field == "data":
                        data.append(value)
                    elif field == "id":
                        seq = value
        return False  # closed early: timeout or network drop
    
    while True:
        try:
            if follow():
                break
        except httpx.HTTPStatusError as exc:
            if exc.response.status_code < 500:
                raise
        except httpx.TransportError as exc:
            print("disconnected:", exc)
        time.sleep(3)

    06Event reference

    Values below are examples. Timestamps in the opening snapshot use a space (2026-10-13 08:01:12+00:00); stored events use ISO 8601 with T. Parse both.
    snapshot First frame on every connection. Full current state of the worker.
    Its id is the latest stored sequence number. If the worker already finished, a terminal frame follows and the stream closes.
    id: 42
    event: snapshot
    data: {"worker_id": "7c1e2a9d-…", "session_id": "b52d7e01-…", "status": "running", "current_item": 18, "last_saved_item": 17, "saved_count": 17, "total_items": 160, "progress_percent": 10.62, "error_code": null, "error_message": null, "queued_at": "2026-10-13 08:01:12.004512+00:00", "started_at": "2026-10-13 08:01:13.220871+00:00", "finished_at": null}
    lifecycle Status change: the worker was queued, or a stop was requested.
    Only two are emitted today: queued on creation and stopping with "reason": "stop_requested". There is no separate "running" lifecycle event; the first item_started means it is running.
    id: 1
    event: lifecycle
    data: {"status": "queued", "total_items": 160, "current_item": 0, "last_saved_item": 0, "progress_percent": 0.0}
    
    id: 37
    event: lifecycle
    data: {"status": "stopping", "reason": "stop_requested"}
    item_started The worker began answering an item.
    Carries only the item number. Use it for a "working on #n" indicator; progress numbers come from item_saved.
    id: 40
    event: item_started
    data: {"item_no": 18}
    item_saved An answer was submitted and stored. This is the progress tick.
    progress_percent is last_saved_item / total_items × 100, rounded to 2 decimals.
    id: 41
    event: item_saved
    data: {"item_no": 18, "total_items": 160, "last_saved_item": 18, "saved_count": 18, "progress_percent": 11.25}
    log Informational message from the run.
    Currently sent once at the end of a run when the exam still reports unanswered items. It does not end the stream.
    id: 322
    event: log
    data: {"level": "warning", "message": "2 items remain unanswered"}
    terminal The worker finished. The server closes the stream after this frame.
    status is completed, failed or cancelled. When the reaper fails a silent worker the payload is short: {"status": "failed", "error_code": "worker_timeout", "stopping": false}.
    id: 323
    event: terminal
    data: {"status": "completed", "error_code": null, "worker_id": "7c1e2a9d-…", "session_id": "b52d7e01-…", "current_item": 160, "last_saved_item": 160, "saved_count": 160, "total_items": 160, "progress_percent": 100.0, "error_message": null, "queued_at": "2026-10-13T08:01:12.004512+00:00", "started_at": "2026-10-13T08:01:13.220871+00:00", "finished_at": "2026-10-13T08:24:50.917334+00:00"}
    stream_timeout The connection hit its maximum lifetime. The worker may still be running.
    No id on this frame. Reconnect with your last Last-Event-ID to continue.
    event: stream_timeout
    data: {"reason": "maximum stream duration"}
    error The worker disappeared between authentication and the first frame.
    Rare: usually the worker was deleted at that moment. Do not retry. Authentication errors never arrive as frames; they come back as a normal JSON response with a 4xx status.
    event: error
    data: {"code": "worker_not_found"}
    HTTP 4xx Rejected before the stream opens. Fix the request; retrying will not help.
    Common codes: 401 apikey_not_found, apikey_revoked, apikey_expired; 403 apikey_scope_denied; 404 worker_not_found.
    HTTP/1.1 403 Forbidden
    Content-Type: application/json
    
    {"error": {"code": "apikey_scope_denied", "message": "This API key does not carry the scope required for this operation", "request_id": "…"}}

    New exam session

    Paste rows from the browser DevTools cookie table (Name, Value, Domain, … separated by tabs). Only Name and Value are used; name=value lines also work. Leave empty to create the session without cookies.

    Replace cookie jar

    Paste rows from the browser DevTools cookie table (Name, Value, Domain, … separated by tabs). Only Name and Value are used; name=value lines also work. Submitting replaces the whole jar for this session; existing cookies are discarded, never merged.

    Create API key

    Scopes

    API key created

    This value cannot be retrieved again after you close this dialog.

    Replace agent cookie

    Paste rows from the browser DevTools cookie table (Name, Value, Domain, … separated by tabs). Only Name and Value are used; name=value lines also work. Admin takeover: replaces the stored cookie, resets the credential to pending and clears any 403 block.

    Saved question

      Confirm