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 strongly recommended. Say what the tile shows, such as MRR, Deploy queue, or Open PRs. Never set title to the bot name. 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 says what the tile shows (MRR, Deploy queue, Open PRs). Never the bot name.
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":"Crew","props":{"state":"working","task":"Comparing flights"},"needs_you":false,"ttl_seconds":120}'
post '{"bot":"Ledger","tile":"mrr","type":"kpi","title":"MRR","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 says what the tile shows (MRR, Deploy queue, Open PRs). Never the bot name.
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":"Crew","props":{"state":"working","task":"Comparing flights"},"needs_you":false,"ttl_seconds":120}'
KPI = '{"bot":"Ledger","tile":"mrr","type":"kpi","title":"MRR","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 says what the tile shows (MRR, Deploy queue, Open PRs). Never the bot name.
// 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":"Crew","props":{"state":"working","task":"Comparing flights"},"needs_you":false,"ttl_seconds":120}';
const KPI =
'{"bot":"Ledger","tile":"mrr","type":"kpi","title":"MRR","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":"Crew","props":{"state":"working","task":"Comparing flights"},"needs_you":false,"ttl_seconds":120} {"bot":"Ledger","tile":"mrr","type":"kpi","title":"MRR","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.
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 wall. Pick type from GET /api/catalog where locked is false.",
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["bot", "type", "props"],
"properties": {
"bot": { "type": "string" },
"tile": { "type": "string" },
"type": { "type": "string" },
"title": { "type": "string", "description": "What the tile shows, such as MRR, Deploy queue, or Open PRs. Never the bot name." },
"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();
}Grok Bot
Paste this into a Grok Bot. Not affiliated with xAI.
Botopticon is a dashboard AI agents build and control, for any agent, script, or cron job that can hit a URL.
POST JSON to <BOTOPTICON_URL>/api/ingest.
Never send transcripts, secrets, customer lists, or HTML.
First GET <BOTOPTICON_URL>/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) 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.
title is strongly recommended. Say what the tile shows, such as MRR, Deploy queue, or Open PRs. Never set title to the bot name.
status: {"bot":"Nova","tile":"crew","type":"status","title":"Crew","props":{"state":"working","task":"Comparing flights"},"needs_you":false,"ttl_seconds":120}
kpi: {"bot":"Ledger","tile":"mrr","type":"kpi","title":"MRR","props":{"value":42000,"unit":"USD"},"needs_you":false,"ttl_seconds":86400}
leaderboard: {"bot":"Closer","tile":"pipeline","type":"leaderboard","title":"Pipeline","props":{"unit":"USD","rows":[{"label":"Acme","value":42000}]},"needs_you":false,"ttl_seconds":86400}
countdown: {"bot":"Nova","tile":"launch","type":"countdown","title":"Launch","props":{"label":"Launch window","until":"2026-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.