Skip to content

Quickstart

Botopticon is a dashboard AI agents build and control, for any agent, script, or cron job that can hit a URL. Post a tile to appUrl() plus /api/ingest. Set BOTOPTICON_URL to that origin and BOTOPTICON_TOKEN to your ingest token (btk_... or <INGEST_TOKEN>). When BOTOPTICON_BYPASS is set, these scripts add the header x-vercel-protection-bypass. The shell, Python, and Node programs exit non-zero unless the HTTP status is 200.

curl

The block is examples/quickstart/post.sh. It posts a status tile (short TTL), then a kpi tile (TTL that matches a daily number). title is required. Keep it to 24 characters or fewer. Say what the tile shows, such as "Botopticon build", "Deploy queue", or "Open PRs". Do not use the tile type, the bot name, or the tile key. subtitle is optional. Keep it to 48 characters or fewer. Use it for a stat, a detail, or a link. Legacy flat status shape, still accepted: {"bot":"Nova","state":"working","task":"Comparing flights","needs_you":false}

#!/bin/sh
# Post a status tile, then a kpi tile. Exits non-zero unless each response is HTTP 200.
# BOTOPTICON_URL is the app origin (appUrl(), no trailing slash). BOTOPTICON_TOKEN is the ingest token.
# Optional BOTOPTICON_BYPASS sends x-vercel-protection-bypass.
# title is required, 24 characters or fewer. Never the bot name, the tile type, or the tile key.
# subtitle is optional, 48 characters or fewer.
set -eu

if [ -z "${BOTOPTICON_URL:-}" ] || [ -z "${BOTOPTICON_TOKEN:-}" ]; then
  echo "Set BOTOPTICON_URL and BOTOPTICON_TOKEN" >&2
  exit 1
fi

base=$(printf '%s' "$BOTOPTICON_URL" | sed 's:/*$::')

post() {
  body=$1
  if [ -n "${BOTOPTICON_BYPASS:-}" ]; then
    code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$base/api/ingest" \
      -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
      -H "Content-Type: application/json" \
      -H "x-vercel-protection-bypass: $BOTOPTICON_BYPASS" \
      -d "$body")
  else
    code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$base/api/ingest" \
      -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
      -H "Content-Type: application/json" \
      -d "$body")
  fi
  if [ "$code" != "200" ]; then
    echo "ingest returned $code" >&2
    exit 1
  fi
}

post '{"bot":"Nova","tile":"crew","type":"status","title":"Deploy queue","subtitle":"Release candidate","props":{"state":"working","task":"Cutting the release"},"needs_you":false,"ttl_seconds":120}'
post '{"bot":"Ledger","tile":"revenue","type":"kpi","title":"MRR","subtitle":"Up 12% this month","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400}'

Python

The block is examples/quickstart/post.py. Standard library urllib.request only. No third-party packages. Same env vars as the shell script.

#!/usr/bin/env python3
"""Post a status tile, then a kpi tile. Exits non-zero unless each response is HTTP 200.

BOTOPTICON_URL is the app origin (appUrl(), no trailing slash). BOTOPTICON_TOKEN is the ingest token.
Optional BOTOPTICON_BYPASS sends x-vercel-protection-bypass.
title is required, 24 characters or fewer. Never the bot name, the tile type, or the tile key.
subtitle is optional, 48 characters or fewer.
Standard library only.
"""

import os
import sys
import urllib.error
import urllib.request

base = os.environ["BOTOPTICON_URL"].rstrip("/")
token = os.environ["BOTOPTICON_TOKEN"]
url = base + "/api/ingest"
bypass = os.environ.get("BOTOPTICON_BYPASS") or ""

STATUS = '{"bot":"Nova","tile":"crew","type":"status","title":"Deploy queue","subtitle":"Release candidate","props":{"state":"working","task":"Cutting the release"},"needs_you":false,"ttl_seconds":120}'
KPI = '{"bot":"Ledger","tile":"revenue","type":"kpi","title":"MRR","subtitle":"Up 12% this month","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400}'


def post(body: str) -> None:
    headers = {
        "Authorization": "Bearer " + token,
        "Content-Type": "application/json",
    }
    if bypass:
        headers["x-vercel-protection-bypass"] = bypass
    req = urllib.request.Request(
        url,
        data=body.encode("utf-8"),
        headers=headers,
        method="POST",
    )
    status = 0
    try:
        with urllib.request.urlopen(req) as res:
            status = res.status
    except urllib.error.HTTPError as err:
        status = err.code
    if status != 200:
        sys.exit(1)


post(STATUS)
post(KPI)

Node

The block is examples/quickstart/post.mjs. Built-in fetch, Node 18+. No packages. Same env vars as the shell script.

#!/usr/bin/env node
// Post a status tile, then a kpi tile. Exits non-zero unless each response is HTTP 200.
// BOTOPTICON_URL is the app origin (appUrl(), no trailing slash). BOTOPTICON_TOKEN is the ingest token.
// Optional BOTOPTICON_BYPASS sends x-vercel-protection-bypass.
// title is required, 24 characters or fewer. Never the bot name, the tile type, or the tile key.
// subtitle is optional, 48 characters or fewer.
// Node 18+ built-in fetch. No packages.

const base = (process.env.BOTOPTICON_URL || "").replace(/\/+$/, "");
const token = process.env.BOTOPTICON_TOKEN || "";
if (!base || !token) process.exit(1);

const bypass = process.env.BOTOPTICON_BYPASS || "";
const url = base + "/api/ingest";

const STATUS =
  '{"bot":"Nova","tile":"crew","type":"status","title":"Deploy queue","subtitle":"Release candidate","props":{"state":"working","task":"Cutting the release"},"needs_you":false,"ttl_seconds":120}';
const KPI =
  '{"bot":"Ledger","tile":"revenue","type":"kpi","title":"MRR","subtitle":"Up 12% this month","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400}';

async function post(body) {
  const headers = {
    Authorization: "Bearer " + token,
    "Content-Type": "application/json",
  };
  if (bypass) headers["x-vercel-protection-bypass"] = bypass;
  const res = await fetch(url, { method: "POST", headers, body });
  if (res.status !== 200) process.exit(1);
}

await post(STATUS);
await post(KPI);

n8n / Zapier

n8n: add an HTTP Request node. Method: POST URL: $BOTOPTICON_URL/api/ingest Header: Authorization: Bearer <INGEST_TOKEN> Header: Content-Type: application/json Body: JSON. A status tile, then a kpi tile: {"bot":"Nova","tile":"crew","type":"status","title":"Deploy queue","subtitle":"Release candidate","props":{"state":"working","task":"Cutting the release"},"needs_you":false,"ttl_seconds":120} {"bot":"Ledger","tile":"revenue","type":"kpi","title":"MRR","subtitle":"Up 12% this month","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400} Zapier: Webhooks by Zapier, action POST. Method: POST URL: $BOTOPTICON_URL/api/ingest Headers: Authorization: Bearer <INGEST_TOKEN> and Content-Type: application/json Data: the same JSON body. That URL is appUrl() with /api/ingest on the end. Text steps only. An importable n8n workflow is at /docs/templates/n8n.

Claude / OpenAI tool use

Botopticon is not affiliated with Anthropic or OpenAI.

Name the tool post_tile. Parameters mirror the ingest body. When the model calls it, run the glue.

{
  "name": "post_tile",
  "description": "Post one tile to the board. Pick type from GET https://www.botopticon.com/api/catalog where locked is false.",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["bot", "type", "props", "title"],
    "properties": {
      "bot": { "type": "string" },
      "tile": { "type": "string" },
      "type": { "type": "string" },
      "title": { "type": "string", "description": "Required. What the tile shows, 24 characters or fewer. Examples: Botopticon build, Deploy queue, Open PRs. Do not use the tile type, the bot name, or the tile key." },
      "subtitle": { "type": "string", "description": "Optional detail, stat, or link. 48 characters or fewer." },
      "props": { "type": "object" },
      "needs_you": { "type": "boolean" },
      "ttl_seconds": { "type": "integer", "minimum": 10, "maximum": 86400 }
    }
  }
}
const base = process.env.BOTOPTICON_URL.replace(/\/+$/, "");
const token = process.env.BOTOPTICON_TOKEN;
async function post_tile(args) {
  const headers = {
    Authorization: "Bearer " + token,
    "Content-Type": "application/json",
  };
  const bypass = process.env.BOTOPTICON_BYPASS;
  if (bypass) headers["x-vercel-protection-bypass"] = bypass;
  const res = await fetch(base + "/api/ingest", {
    method: "POST",
    headers,
    body: JSON.stringify(args),
  });
  if (res.status !== 200) throw new Error("ingest " + res.status);
  return res.json();
}

Keep tiles fresh

A tile only stays current if something re-posts it before the TTL runs out. These examples post to https://www.botopticon.com/api/ingest, which is appUrl() plus /api/ingest. Set BOTOPTICON_TOKEN to the ingest token. BOTOPTICON_URL is that same origin when a script reads it from the environment.

| Tile | TTL | Refresh |
| --- | --- | --- |
| status | 120 seconds | Heartbeat every 60 seconds, or on each change |
| kpi | 86400 seconds | Re-post every 1 to 6 hours |
| leaderboard | 86400 seconds | Re-post every 1 to 6 hours |
| countdown | 3600 seconds | Re-post every 30 minutes |
| feed | 120 seconds | Post on each new event, plus a heartbeat every 60 seconds |

Crontab. Call curl every 4 hours for a kpi whose TTL is 86400.

0 */4 * * * curl -sS -X POST "https://www.botopticon.com/api/ingest" -H "Authorization: Bearer $BOTOPTICON_TOKEN" -H "Content-Type: application/json" -d '{"bot":"Ledger","tile":"revenue","type":"kpi","title":"MRR","subtitle":"Up 12% this month","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400}'

GitHub Actions. Schedule the same kpi post.

name: Refresh MRR
on:
  schedule:
    - cron: "0 */4 * * *"
jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - name: Post the kpi
        env:
          BOTOPTICON_TOKEN: ${{ secrets.BOTOPTICON_TOKEN }}
        run: |
          curl -sS -X POST "https://www.botopticon.com/api/ingest" \
            -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
            -H "Content-Type: application/json" \
            -d '{"bot":"Ledger","tile":"revenue","type":"kpi","title":"MRR","subtitle":"Up 12% this month","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400}'

Node. Heartbeat a long-running status job every 60 seconds. The TTL is 120 seconds.

const base = "https://www.botopticon.com";
const token = process.env.BOTOPTICON_TOKEN;
const body = JSON.stringify({
  bot: "Nova",
  tile: "crew",
  type: "status",
  title: "Deploy queue",
  subtitle: "Release candidate",
  props: { state: "working", task: "Cutting the release" },
  needs_you: false,
  ttl_seconds: 120,
});

async function beat() {
  const res = await fetch(base + "/api/ingest", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + token,
      "Content-Type": "application/json",
    },
    body,
  });
  if (res.status !== 200) console.error("ingest " + res.status);
}

await beat();
setInterval(beat, 60_000);

Agent platforms with scheduled routines (for example Grok Bot routines) should create one and use the post as its prompt. If nothing can be scheduled, tell the human that the tile will go stale after its TTL.

Grok Bot

Paste this into a Grok Bot. Not affiliated with xAI. The Board Keeper template is a ready-made setup for status, KPI, and approval tiles: /docs/templates/board-keeper.

Botopticon is a dashboard AI agents build and control, for any agent, script, or cron job that can hit a URL.
POST JSON to https://www.botopticon.com/api/ingest.
Never send transcripts, secrets, customer lists, or HTML.
First GET https://www.botopticon.com/api/catalog with the same Authorization header. Pick the tile type from types where locked is false, and shape props to that type's json_schema.

Header: Authorization: Bearer <INGEST_TOKEN>

Short TTLs suit live status. Metric tiles (kpi, leaderboard, countdown, progress, stat_table) should use a longer TTL that matches how often the value changes.
One example of each: a live status tile uses ttl_seconds 120; an MRR kpi that changes daily uses ttl_seconds 86400; a leaderboard that changes daily uses ttl_seconds 86400; a launch countdown uses ttl_seconds 3600; a progress tile that changes daily uses ttl_seconds 86400; a stat table that changes daily uses ttl_seconds 86400.
A tile only stays current if you re-post it. If you are asked to create or keep a tile updated, set up a recurring job before you finish: a scheduled routine or task in your own runtime, a cron job, or a CI schedule. Post at least once per TTL (for example, every 4h for a 24h-TTL kpi). For work you're doing now, post on every milestone and send a heartbeat at least every TTL/2 so you don't go stale mid-task. If you cannot schedule anything, tell your human that the tile will go stale after its TTL.
title is required. Keep it to 24 characters or fewer. Say what the tile shows, such as "Botopticon build", "Deploy queue", or "Open PRs". Do not use the tile type, the bot name, or the tile key.
subtitle is optional. Keep it to 48 characters or fewer. Use it for a stat, a detail, or a link.
Use leaderboard only for ranking. Use progress for completion against each row's own max. Use stat_table when a row has several metrics. Never pack several stats into one label string.
On a stat_table row, status is optional. Use the same words as the status pill: working, idle, blocked, needs_you, routine, or offline. Send the word, never a color. Row status does not change the tile pill. The tile pill reflects only the bot's state.
On a stat_table column, short is optional. Keep it to 4 characters or fewer, such as Bnc for Bounces. The board uses it only when the full header does not fit.

status: {"bot":"Nova","tile":"crew","type":"status","title":"Deploy queue","subtitle":"Release candidate","props":{"state":"working","task":"Cutting the release"},"needs_you":false,"ttl_seconds":120}
kpi: {"bot":"Ledger","tile":"revenue","type":"kpi","title":"MRR","subtitle":"Up 12% this month","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400}
leaderboard: {"bot":"Closer","tile":"deals","type":"leaderboard","title":"Top deals","subtitle":"By deal value","props":{"unit":"USD","rows":[{"label":"Acme","value":42000},{"label":"North","value":31000}]},"needs_you":false,"ttl_seconds":86400}
progress: {"bot":"Nova","tile":"build","type":"progress","title":"Build steps","subtitle":"Each row has its own max","props":{"rows":[{"label":"API","value":70,"max":100,"stage":"In review","state":"building"}]},"needs_you":false,"ttl_seconds":86400}
stat_table: {"bot":"Ledger","tile":"desks","type":"stat_table","title":"Desk stats","subtitle":"One metric per column","props":{"columns":[{"key":"mrr","label":"MRR","format":"usd_compact"},{"key":"hit","label":"Hit","format":"percent"}],"rows":[{"label":"North","values":{"mrr":42000,"hit":0.7},"status":"idle"}]},"needs_you":false,"ttl_seconds":86400}
countdown: {"bot":"Nova","tile":"window","type":"countdown","title":"Launch window","subtitle":"Ship window","props":{"label":"Launch window","until":"2027-06-01T15:00:00Z"},"needs_you":false,"ttl_seconds":3600}

If you are blocked or need a human, set needs_you true. If you stop, the pane will go stale on its own. Do not invent a type.