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
writeor 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/sdkinstalled withnpm install @botseon/sdk(orpnpm 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
botseonpackage installed withpip install botseon. It uses the standard library only. - Two environment variables:
BOTSEON_API_KEY, which is required, andBOTSEON_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.
-
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
--keyflag for exactly that reason. -
Point the command at your install with
BOTSEON_API_URL. It must be an absolutehttporhttpsURL, and plainhttpis admitted only for this machine. -
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. -
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.
-
Branch on the exit code rather than on the text:
0the run completed,1it did not,2the wait timed out,3the command refused before sending,4the service could not be reached,130the command was interrupted (Ctrl-CorSIGTERM). -
For a machine reader, add
--json. You then get exactly one line on standard output, whatever the outcome, carryingschemaVersion, 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:
| TypeScript | Python |
|---|---|
createClient({ baseUrl, apiKey }) | Client(base_url, api_key), both defaulting to the environment |
runTask | run_task |
sendTask | send_task |
waitForRun | wait_for_run |
cancelRun | cancel_run |
resolveBot | resolve_bot |
listBots | list_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 see | Why | What 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.