Start here

  • Getting started
  • Backends
  • Connect your coding agent
  • How it works

Declaring your app

  • Actions, surfaces & elements
  • Hand mode
  • Control & confirmation

Polish & operate

  • Appearance
  • The Watch panel

Reference

  • Advanced
  • Configuration
  • Browser & framework support
  • Troubleshooting & FAQ
All docs

Docs

Backends

Next.js is a one-line drop-in; every other JS backend mints with mintSession. Which stacks fit, and the JS-only caveat.

The backend half of Coworkkit does one job: mint a short-lived session token from your secret key (why it works this way). On Next.js that's a one-line drop-in; on any other JavaScript backend it's mintSession — a single call you wrap in your own route. And on any backend at all — PHP, Python, Ruby, Go, anything that can make an HTTPS request — you call the mint endpoint directly. Every stack fits.

Next.js — drop-in

coworkkitSessionRoute is a ready-made POST handler. One line, and your backend half is done.

Any other JS backend — mintSession

One fetch-based call you wrap in your own route — Node, Bun, Deno, and edge runtimes.

ExpressFastifyHonoKoaNestJSCloudflare WorkersBunDeno
Any backend — the wire contract

Not on JavaScript? POST /session yourself — the same request mintSession makes. Copy-paste recipes below.

PHPPythonRubyGo

On Next.js, coworkkitSessionRoute from @coworkkit/server/next is the drop-in — the five-step Getting started walks it end to end. Everything below is for every other stack.

Any other JS backend — mintSession

Call mintSession(apiKey, { userId }) inside your own route and return what it gives you — { token, serverUrl, cloudUrl }. It's a plain async function built on fetch, so it doesn't care which framework hosts it. Express, for example:

server.ts (Express)
import { mintSession } from "@coworkkit/server";

// COWORKKIT_API_KEY stays on the server — the browser only receives the short-lived token.
// `userId` is the user the agent acts as. "dev-user" is fine while developing; in production
// derive it from your own auth/session middleware, server-side — never from the client.
app.post("/api/session", async (_req, res) => {
  try {
    const session = await mintSession(process.env.COWORKKIT_API_KEY!, { userId: "dev-user" });
    res.json(session);
  } catch (err) {
    res.status(502).json({ error: err instanceof Error ? err.message : "session mint failed" });
  }
});

The same call drops into Fastify, Koa, and NestJS the same way. And because coworkkitSessionRoute is really just mintSession wrapped as a Web-standard Request → Response handler, on a fetch-native runtime — Cloudflare Workers, Bun, Deno, Hono — you can drop it in directly instead.

Edge caveat: on the default path mintSession reads the control URL from process.env, so on a runtime without Node's process — a bare Cloudflare Worker (no nodejs_compat) or Deno without --allow-env — pass cloudUrl to mintSession explicitly, which skips the env read. Node and Bun need nothing extra.

Any backend — the wire contract

Under the hood, mintSession makes exactly one HTTPS request. That endpoint is public and language-blind, so on PHP, Python, Ruby, Go — or anything that speaks HTTP — you make the same request yourself. Hold your key, post the user's id, relay the JSON back to the browser's getToken. No Node, no SDK, no extra service on your side.

POST /session
POST {base}/session         base: env COWORKKIT_CLOUD_URL, default https://api.coworkkit.ai

  header   x-api-key: <your secret tenant key>      (never logged, never in the browser)
  header   content-type: application/json
  body     {"userId": "<derived server-side from your own auth>"}

-> 2xx      {"token": "...", "serverUrl": "...", "cloudUrl": "..."}   relay UNCHANGED to getToken
-> 4xx/5xx  {"error": "...", "reason": "..."}                         reason optional; relay the status

Two rules make this safe. Your apiKey is a server-only secret: it never reaches the browser and never lands in a log line or an error message — build your error strings from the HTTP status and the server's error/reason, never the key. And userId is derived server-side from your own authenticated session, never accepted from the browser. The minted token authenticates the agent as exactly that user, so trusting a client-supplied userId would let any visitor act as anyone — the impersonation boundary is the whole reason this call lives on the server.

Relay the success body unchanged. The contract grows additively — success bodies may gain fields over time — so forward the whole object you receive; don't cherry-pick token/serverUrl/cloudUrl and drop the rest. The browser SDK reads what it needs, and forwarding everything keeps you forward-compatible for free.

On a rejected /session the service may attach a reason — a short machine token you can map to your own user-facing copy:

reasonwhat it means
missing_api_keyno x-api-key header was sent
unknown_api_keythe key doesn't match any tenant
tenant_revokedthe tenant's key has been revoked
missing_userthe request carried no userId
session_capthe tenant is at its concurrent-session limit
out_of_creditthe tenant's credit balance is exhausted
unmeteredthe tenant has no credit balance provisioned at all
transport_unassignedthe tenant's voice transport is unassigned — the session can't be dispatched

reason is optional, not guaranteed. An auth-shaped reject — a bad or missing key, a missing userId — comes back as just { "error": "..." } (in practice a 401 { "error": "unknown apiKey" }, or a 400 { "error": "userId required" }), while a policy reject like out_of_credit carries the matching reason. So branch on the HTTP status, use reason when it's there, and treat it as an open set — a value you don't recognize is not an error in your integration.

Framework-free reference recipes follow — around 40 lines of your language's standard library (PHP uses ext-curl). Paste one, set COWORKKIT_API_KEY on the server (and COWORKKIT_CLOUD_URL only if you're not on the default), and your existing frontend connects. Each returns the decoded body unchanged and raises a typed error carrying the HTTP status and reason on any non-2xx status — or a non-JSON body, so an HTML error page never becomes a raw parse crash.

PHP

ext-curl only, PHP 8.1+. coworkkit_mint($userId) returns the session array; reads the key from COWORKKIT_API_KEY.

coworkkit-session.php
<?php
// Coworkkit — mint a voice session from PHP (bring-your-own-backend).
// The whole server side of the closed loop: hold the secret key, POST /session,
// relay the JSON. Framework-free, ext-curl only, PHP >= 8.1. See backends/README.md.

/** Thrown when /session returns a non-2xx status or a non-JSON body. */
class CoworkkitException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $status = null,
        public readonly ?string $reason = null,
    ) {
        parent::__construct($message);
    }
}

/**
 * Mint a session. Derive $userId from YOUR OWN auth, server-side — never trust the
 * browser (KB-0001). Returns the decoded body unchanged, so unknown fields survive.
 */
function coworkkit_mint(string $userId): array
{
    $base = getenv('COWORKKIT_CLOUD_URL') ?: 'https://api.coworkkit.ai';
    $apiKey = getenv('COWORKKIT_API_KEY') ?: '';

    $ch = curl_init("{$base}/session");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5, // a stalled upstream must not pin this worker forever
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => ['content-type: application/json', "x-api-key: {$apiKey}"],
        CURLOPT_POSTFIELDS => json_encode(['userId' => $userId]),
    ]);
    $text = curl_exec($ch);
    $curlErr = $text === false ? curl_error($ch) : null;
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    // (No curl_close: the CurlHandle frees itself when it goes out of scope; the call
    // is a deprecated no-op since PHP 8.0.)

    $body = json_decode((string) $text, true);
    $ok = is_array($body);
    if ($status < 200 || $status >= 300 || !$ok) {
        // Transport failure or non-JSON (HTML error/placeholder) fails clearly —
        // never an unhandled parse crash.
        $detail = $ok && isset($body['error'])
            ? (string) $body['error']
            : ($curlErr !== null
                ? "unreachable: {$curlErr} — is the service deployed/reachable?"
                : 'non-JSON response — is the service deployed/reachable?');
        $reason = $ok && isset($body['reason']) && is_string($body['reason']) ? $body['reason'] : null;
        throw new CoworkkitException("Coworkkit /session failed (HTTP {$status}): {$detail}", $status, $reason);
    }
    return $body; // relay unchanged — the contract grows additively
}

Python

Standard library only (urllib) — no pip dependency. mint_session(user_id) returns the session dict.

coworkkit_session.py
"""Coworkkit — mint a voice session from Python (bring-your-own-backend).

The whole server side of the closed loop: hold the secret key, POST /session,
relay the JSON. Stdlib only (urllib), Python 3.8+ — no pip dependency.
See backends/README.md.
"""

from __future__ import annotations

import json
import os
import urllib.error
import urllib.request


class CoworkkitError(Exception):
    """Raised when /session returns a non-2xx status or a non-JSON body."""

    def __init__(self, message: str, status: int | None = None, reason: str | None = None):
        super().__init__(message)
        self.status = status
        self.reason = reason


def mint_session(user_id: str) -> dict:
    """Mint a session. Derive user_id from YOUR OWN auth, server-side (KB-0001).

    Returns the decoded body unchanged, so unknown fields survive.
    """
    base = os.environ.get("COWORKKIT_CLOUD_URL") or "https://api.coworkkit.ai"
    req = urllib.request.Request(
        f"{base}/session",
        data=json.dumps({"userId": user_id}).encode(),
        headers={
            "content-type": "application/json",
            "x-api-key": os.environ.get("COWORKKIT_API_KEY", ""),
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:  # noqa: S310 (fixed https scheme)
            status, text = resp.status, resp.read().decode()
    except urllib.error.HTTPError as exc:  # 4xx/5xx still carry a body
        status, text = exc.code, exc.read().decode()
    except urllib.error.URLError as exc:  # unreachable/timeout — typed error, never a raw traceback
        raise CoworkkitError(f"Coworkkit /session unreachable: {exc.reason} — is the service deployed/reachable?") from exc

    try:
        body = json.loads(text)
        ok = isinstance(body, dict)
    except ValueError:  # non-JSON (HTML error/placeholder) — never crash on parse
        body, ok = None, False

    if not 200 <= status < 300 or not ok:
        detail = str(body["error"]) if ok and "error" in body else "non-JSON response — is the service deployed/reachable?"
        reason = body.get("reason") if ok and isinstance(body.get("reason"), str) else None
        raise CoworkkitError(f"Coworkkit /session failed (HTTP {status}): {detail}", status, reason)
    return body  # relay unchanged — the contract grows additively

Ruby

Standard library only (net/http). coworkkit_mint(user_id) returns the session hash.

coworkkit_session.rb
# Coworkkit — mint a voice session from Ruby (bring-your-own-backend).
# The whole server side of the closed loop: hold the secret key, POST /session,
# relay the JSON. Stdlib only (net/http). See backends/README.md.

require 'net/http'
require 'json'
require 'openssl' # referenced in the transport rescue even on plain-http runs
require 'uri'

# Raised when /session returns a non-2xx status or a non-JSON body.
class CoworkkitError < StandardError
  attr_reader :status, :reason

  def initialize(message, status = nil, reason = nil)
    super(message)
    @status = status
    @reason = reason
  end
end

# Mint a session. Derive user_id from YOUR OWN auth, server-side (KB-0001).
# Returns the parsed body unchanged, so unknown fields survive.
def coworkkit_mint(user_id)
  base = ENV['COWORKKIT_CLOUD_URL']
  base = 'https://api.coworkkit.ai' if base.nil? || base.empty?
  uri = URI("#{base}/session")

  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == 'https'
  http.open_timeout = 5  # a stalled upstream must not pin this thread forever
  http.read_timeout = 15
  req = Net::HTTP::Post.new(uri)
  req['content-type'] = 'application/json'
  req['x-api-key'] = ENV['COWORKKIT_API_KEY'] || ''
  req.body = JSON.generate('userId' => user_id)
  begin
    res = http.request(req)
  rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, EOFError => e
    # Unreachable/timeout — typed error, never a raw exception out of the recipe.
    raise CoworkkitError, "Coworkkit /session unreachable: #{e.message} — is the service deployed/reachable?"
  end

  status = res.code.to_i
  begin
    body = JSON.parse(res.body)
    ok = body.is_a?(Hash)
  rescue JSON::ParserError # non-JSON (HTML error/placeholder) — never crash on parse
    body = nil
    ok = false
  end

  if status < 200 || status >= 300 || !ok
    detail = ok && body.key?('error') ? body['error'].to_s : 'non-JSON response — is the service deployed/reachable?'
    reason = ok && body['reason'].is_a?(String) ? body['reason'] : nil
    raise CoworkkitError.new("Coworkkit /session failed (HTTP #{status}): #{detail}", status, reason)
  end
  body # relay unchanged — the contract grows additively
end

Go

Standard library only. MintSession(apiKey, userID) returns the decoded body; Go takes the key as an explicit argument rather than from the environment.

coworkkit_session.go
// Package coworkkitmint mints Coworkkit voice sessions from Go
// (bring-your-own-backend). The whole server side of the closed loop: hold the
// secret key, POST /session, relay the JSON. Stdlib only. See backends/README.md.
package coworkkitmint

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

// http.DefaultClient never times out; a stalled upstream must not pin a goroutine forever.
var httpClient = &http.Client{Timeout: 15 * time.Second}

// CoworkkitError is returned when /session gives a non-2xx status or a non-JSON body.
type CoworkkitError struct {
	Status  int
	Reason  string
	Message string
}

func (e *CoworkkitError) Error() string { return e.Message }

// MintSession mints a session. Derive userID from YOUR OWN auth, server-side
// (KB-0001); apiKey is your secret tenant key. Returns the decoded body unchanged,
// so unknown fields survive.
func MintSession(apiKey, userID string) (map[string]any, error) {
	base := os.Getenv("COWORKKIT_CLOUD_URL")
	if base == "" {
		base = "https://api.coworkkit.ai"
	}
	payload, _ := json.Marshal(map[string]string{"userId": userID})
	req, err := http.NewRequest(http.MethodPost, base+"/session", bytes.NewReader(payload))
	if err != nil {
		return nil, &CoworkkitError{Message: err.Error()}
	}
	req.Header.Set("content-type", "application/json")
	req.Header.Set("x-api-key", apiKey)

	res, err := httpClient.Do(req)
	if err != nil {
		return nil, &CoworkkitError{Message: fmt.Sprintf("Coworkkit /session unreachable: %v — is the service deployed/reachable?", err)}
	}
	defer res.Body.Close()
	text, _ := io.ReadAll(res.Body)

	var body map[string]any
	ok := json.Unmarshal(text, &body) == nil // non-JSON (HTML) → ok=false, never a crash

	if res.StatusCode < 200 || res.StatusCode >= 300 || !ok {
		detail := "non-JSON response — is the service deployed/reachable?"
		reason := ""
		if ok {
			if e, isStr := body["error"].(string); isStr {
				detail = e
			}
			if r, isStr := body["reason"].(string); isStr {
				reason = r
			}
		}
		return nil, &CoworkkitError{
			Status:  res.StatusCode,
			Reason:  reason,
			Message: fmt.Sprintf("Coworkkit /session failed (HTTP %d): %s", res.StatusCode, detail),
		}
	}
	return body, nil // relay unchanged — the contract grows additively
}

These recipes aren't just examples: each one is tested against the same fake /session as our own Node package — success, unknown extra fields, an out_of_credit rejection, and non-JSON error pages. That's the bar for a community port too — a port in your language is official when it passes the same conformance suite mintSession does.

Want the route written for you? Your Coworker's Quickstart tab generates a ready-to-paste setup prompt — with the exact token route for Next.js, Vite + Express, and Remix — that you hand to your AI coding agent.

PreviousGetting startedNextConnect your coding agent

Machine-readable: this page as Markdown · all docs (llms.txt)