The TypeScript and Python SDKs and headless mode

Developer·8 minutes to read

What this does for you

You send a bot a task from a script or a build step and get its answer back on standard output. The same calls are in a TypeScript package and a Python package: start a task, wait for it, read its result, and list your bots.

Before you start

  • Your personal API key of scope write or wider, made on Settings → API keys (/settings/api-keys). Any member makes one with nobody to ask, unless an administrator of a team has limited key creation to administrators; on your own you are your personal organisation's owner and are never limited. An organisation key works too (Authentication and keys).
  • For TypeScript: Node 22 or later, and @botseon/sdk installed with npm install @botseon/sdk (or pnpm add, yarn add). The package ships compiled JavaScript with type declarations and one runtime dependency, zod.
  • For Python: Python 3.9 or later, and the botseon package installed with pip install botseon. It uses the standard library only.
  • Two environment variables: BOTSEON_API_KEY, which is required, and BOTSEON_API_URL, which defaults to the loopback address a home install answers on. Neither value is ever printed, by the library or by the command.
  • The exact name, or the id, of the bot you are sending to. You can read both from Bots.

Steps

These steps run at a terminal and in a build log; the library has no screen of its own, so no screenshot is shown here. The one product screen a headless task touches is the conversation it appears in, unchanged: the task shows up as a message from the person the key belongs to, and the bot's answer follows as it would for anything typed in the app.

  1. Put the key in the environment, never on the command line. A key on a command line is visible to every process and every log, and the command refuses a --key flag for exactly that reason.

  2. Point the command at your install with BOTSEON_API_URL. It must be an absolute http or https URL, and plain http is admitted only for this machine.

  3. Send the task. The command's grammar is:

    botseon-task --bot <id|name> [--wait | --no-wait] [--timeout <duration>] [--json] [--quiet]
                  [--cancel-on-timeout] [--stop-on-approval] [--] <text> | -
    

    - in place of the text reads it from standard input.

  4. Read the streams apart. Progress goes to standard error, one line per state change; the answer goes to standard output. A build log therefore shows the progress and a pipe carries only the answer.

  5. Branch on the exit code rather than on the text: 0 the run completed, 1 it did not, 2 the wait timed out, 3 the command refused before sending, 4 the service could not be reached, 130 the command was interrupted (Ctrl-C or SIGTERM).

  6. For a machine reader, add --json. You then get exactly one line on standard output, whatever the outcome, carrying schemaVersion, the outcome, the run and conversation ids, the state, the text, the last result card, the spend in micro-cents, the steps, the elapsed milliseconds and the request id.

A worked example

Check that the key and the origin are right before a build step uses them:

curl -sS "$BOTSEON_API_URL/api/v1/bots?limit=5" \
  -H "authorization: Bearer $BOTSEON_API_KEY" \
  -H "accept: application/json"

The same work from the library, which is what the command is built on:

import { createClient } from '@botseon/sdk';

const client = createClient({
  baseUrl: process.env.BOTSEON_API_URL ?? '',
  apiKey: process.env.BOTSEON_API_KEY ?? '',
});

const result = await client.runTask({
  bot: { name: 'Inbox Triage' },
  text: 'Draft the release notes for v1.4.',
  timeoutMs: 900_000,
  onState: (run) => console.error(`run ${run.id} ${run.state}`),
});

if (result.outcome !== 'completed') process.exitCode = 1;
console.log(result.text);

runTask is three steps in one: it sends, it polls until the run settles, and it collects the bot's messages for that run into one piece of text. sendTask, waitForRun, cancelRun, resolveBot and listBots are on the same client when you want the steps apart.

The same work from Python

import sys

from botseon import Client, NotFoundError, TaskTimeoutError

client = Client()  # reads BOTSEON_API_URL and BOTSEON_API_KEY

for bot in client.list_bots():
    print(bot.id, bot.name)

try:
    result = client.run_task(
        "Draft the release notes for v1.4.",
        bot_name="Inbox Triage",
        timeout=900,
        on_state=lambda run: print(f"run {run.id} {run.state}", file=sys.stderr),
    )
except NotFoundError:
    sys.exit("No bot with that name is visible to this key.")
except TaskTimeoutError as err:
    sys.exit(f"Still running: {err.run_id}")

print(result.text)

The Python client has the TypeScript one's calls under Python names, with durations in seconds rather than milliseconds:

TypeScriptPython
createClient({ baseUrl, apiKey })Client(base_url, api_key), both defaulting to the environment
runTaskrun_task
sendTasksend_task
waitForRunwait_for_run
cancelRuncancel_run
resolveBotresolve_bot
listBotslist_bots
(the run, from waitForRun)get_run
(the result, inside runTask)get_result

Every failure is an exception under BotseonError, one class per code, so you catch the case you care about: UnauthenticatedError, ForbiddenError, NotFoundError, InvalidRequestError, ConflictError, RateLimitedError, UnavailableError, InternalError, ProtocolError, UnreachableError and TaskTimeoutError. Each carries code, status, request_id, details and retryable; a busy bot's ConflictError also carries retry_after in seconds. The TypeScript client throws one SdkError with the same code values.

The Python package installs the same botseon-task command, with the grammar and exit codes above plus --list-bots (with or without --json) to list your bots. python -m botseon runs the same command.

Every other endpoint

The task calls above are a convenience over a layer that covers the whole API: every operation in the OpenAPI document, generated from it, one method each, named after the operation's operationId. In TypeScript the methods are on client.api and take { path, query, body }; in Python they are on client.api too, with path parameters as arguments and query= and body= as keywords.

import { createClient } from '@botseon/sdk';

const client = createClient({
  baseUrl: process.env.BOTSEON_API_URL ?? '',
  apiKey: process.env.BOTSEON_API_KEY ?? '',
});
const botId = process.env.BOTSEON_BOT_ID ?? ''; // a bot's id, from `listBots` or `resolveBot`
const endpointId = process.env.BOTSEON_WEBHOOK_ID ?? ''; // an endpoint's id, from `client.api.webhooksList()`

const bot = await client.api.botsGet({ path: { id: botId } });
const page = await client.api.runsList({ query: { limit: 20, state: 'failed' } });
await client.api.webhooksDelete({ path: { id: endpointId } });
bot = client.api.bots_get(bot_id)
page = client.api.runs_list(query={"limit": 20, "state": "failed"})
client.api.webhooks_delete(endpoint_id)

Each method returns the success body as the document describes it (nothing, for a 204) and fails with the same SdkError or BotseonError subclass as the task calls. The request and response types are generated too: Schemas.BotDto, Schemas.RunsListQuery and the rest in TypeScript, and a TypedDict per schema in botseon._generated for Python. An operation that needs an Idempotency-Key is sent a fresh one unless you pass your own ({ idempotencyKey } as the second argument in TypeScript, idempotency_key= in Python); pass your own when you retry the same attempt. OPENAPI_VERSION names the document version the layer was generated from.

Verifying webhook deliveries

Both packages check a webhook delivery's signature for you. Hand them the endpoint's signing secret, the request headers and the raw body, exactly as it arrived, and answer 401 when the check fails:

import { verifyWebhook } from '@botseon/sdk';

export async function handleDelivery(request: Request): Promise<Response> {
  const ok = await verifyWebhook({
    secret: process.env.BOTSEON_WEBHOOK_SECRET ?? '',
    headers: request.headers,
    rawBody: await request.text(),
  });
  if (!ok) return new Response(null, { status: 401 });
  // …handle the event…
  return new Response(null, { status: 204 });
}
from botseon import verify_webhook

if not verify_webhook(secret, request.headers, request.get_data()):
    abort(401)

The check is the one the server signs with: the hex HMAC-SHA256 of v1:<timestamp>:<raw body>, compared in constant time, with the timestamp no more than 300 seconds from your clock either way. It answers false for anything else and never throws on a bad delivery. The TypeScript check is async because it uses WebCrypto, so it also runs in a browser, a worker or an edge function. Use x-botseon-delivery to drop a delivery you have already handled.

Testing your own code against a fake

Both packages are tested against the same in-memory stand-in for the API, which the TypeScript package exports for your tests as well: createFakeApi from @botseon/sdk/test/fake-api. Hand its handle to createClient as fetch and no server is needed. The Python Client takes the same kind of seam, transport, a function from a request to a status, headers and body.

What can go wrong

What you seeWhyWhat to do
BOTSEON_API_KEY is not set.The variable is unset or empty in the environment the command inherited.Export it in the same shell, or pass it into the build step's environment.
--key is not accepted: a key on the command line is visible to every process and every log. Set BOTSEON_API_KEY instead.A --key flag appeared anywhere in the arguments.Move the key into the environment.
BOTSEON_API_URL is not an absolute http or https URL.The variable is set to something that is not a URL, or to a bare host.Give it a scheme and a host.
The API key was not accepted. Check BOTSEON_API_KEY and whether the key has expired or been revoked.The key is unknown, revoked, expired, or its person is no longer a member (or, for an admin key, no longer an administrator).Check the key's row on Settings → API keys.
No bot with that id or name is visible to this key.The name matches nothing this key's person can see, or the id is not a real one.List the bots first and copy the name exactly.
More than one bot has that name. Use the id instead.Two bots share the name you gave.Send to the id; the command prints the candidates.
The bot is still working on an earlier task. Try again when it has finished.The bot had a run in flight for the whole queue timeout.Raise --timeout, or wait and send again.
The wait ran out before the run settled.The run was still going when the timeout passed.Read the run id from the message and follow it with Runs, or add --cancel-on-timeout.

See also

Last verified against build c0f77aa.