For a team putting their own product on Telemachus:
their palette, their tools, their screens, their API. Nothing here is a
fork — every seam below is a supported extension point, and the worked
example is refimpl/racketmaximus/plugins/integrator-demo/,
which fills all four of them and is exercised by
test/server-smoke.sh.
Reference material, generated from the source and always current: docs/reference/
— sdk.md for the authoring surfaces, api.md for every route, plugins.md for the seams, permissions.md for the permission
catalog.
| You want | You get | How |
|---|---|---|
| Your name, logo and palette on the console | All of it | §2 — the branding document |
| Your own capabilities the model can call | Tools, RBAC-checked like built-ins | §3 |
| Your own screens | Served by the platform, behind a bearer token | §4 |
| Your own API | Routes under a prefix that cannot collide | §5 |
| Background work | Job kinds on the platform's queue and quotas | §6 |
| Your marketing funnel | Three render tiers, fully skinnable | §7 |
What is not available today, stated plainly so you do not design around it:
static/index.html.chat, agent, translate,
search and workflows can each be switched off
— POST /api/features/<name> {"enabled":false},
permission settings:manage, and the console drops the tab
as well as refusing the endpoint. But "this deployment has no chat"
means doing it for every team; there is no instance-wide switch yet.
Individual tools switch the same way
(POST /api/tools/<name>).The branding document carries the palette, and it is the same token vocabulary the beta funnel uses — so you write your palette once and every surface wears it.
curl -X PUT $BASE/api/branding -H "Authorization: Bearer $OP" -d '{
"title": "Acme Trade",
"tagline": "Import compliance, in-house",
"theme": {
"bg": "#101014", "surface": "#1b1b22", "ink": "#f5f5f7", "muted": "#a0a0ad",
"brand": "#c9a227", "brandInk": "#ffffff",
"radius": "12px", "mode": "dark", "fontBody": "Serif"
}
}'| Token | What it paints |
|---|---|
bg |
the page ground |
surface |
cards, panels, the header |
ink |
body text |
muted |
secondary text — timestamps, counts, "no results" |
brand |
links, active tabs, the button ground (deepened) |
brandInk |
text on a brand-coloured button |
radius |
corner radius, 0px–16px |
mode |
dark or light |
fontBody |
System, Serif, Mono or
Rounded |
There is an editor at Admin → Branding that previews
live. The logo goes up separately (POST /api/branding/logo)
and replaces both the mark and the wordmark.
Four things worth knowing before you fight the API:
--panel2,
hairlines and the button's hover state are derived from them, so a theme
stays the few decisions you actually want to make.GET /api/branding is public, because
the sign-in screen renders it before anyone has a token. Writes are
instance:manage.With TELEMACHUS_MULTITENANT=1, each company gets its own
branding and its own theme — one more document, the same
shape:
curl -X PUT $BASE/api/org/branding -H "Authorization: Bearer $ORG_ADMIN" -d '{ … }'Resolution: the Host header first (the superadmin
sets orgs.domain, so a company on acme.example
is themed before sign-in), then the caller's own org once a bearer
resolves. A company that has set nothing wears the instance's branding
whole — there is no per-field merge, because a
half-branded console is the confusing outcome.
A tool is a capability the model may call. It registers through the same registry as the built-ins, so per-tool RBAC and per-team activation apply for free.
(provide tools)
(define tools (list (list "shipment_eta" shipment-eta-schema "chat:use" shipment-eta)))
;; handler: (conn principal args) -> a string, a jsexpr, or an artifact
The schema is plain OpenAI-compatible function JSON;
define-tool in sdk.md
writes it for you. Return an artifact when the result
names something rather than carrying it (a document, a table, a
link) — the model gets one line, the console renders a card, a workflow
step keeps the whole value.
Put them in plugins/<id>/bundle/. The platform
serves that directory at
/api/x/<id>/bundle/*path to a caller
with a bearer token, answered
Cache-Control: private, no-store.
plugins/acme/bundle/index.html → GET /api/x/acme/bundle/
plugins/acme/bundle/app.js → GET /api/x/acme/bundle/app.js
bundle/ is reserved by the platform
under your prefix, and core routes match before plugin ones, so do not
declare a route there.GET /api/branding
with the bearer and map the same tokens onto your own CSS variables —
plugins/integrator-demo/bundle/index.html does exactly this
in about fifteen lines, and a company's own theme answers on any host
when the bearer is sent.plugins/<id>/landing/ is the other
directory: public, cached, for a marketing funnel (§7). Never put
customer screens there.(provide routes)
(define routes
(list (list "GET" "/eta" "chat:use" eta-route "Estimated days for ?lane=.")
(list "POST" "/eta" "chat:use" eta-route "Estimated days for {lane}.")))
;; handler: (conn principal args) -> jsexpr, args = {params, query, body}
Mounted at
/api/x/<plugin-id>/<path> —
the prefix is platform-fixed, so you can never shadow a core route or
another plugin's. Every route requires a bearer token; the permission
you name is checked through can? before your handler runs.
A raise-user-error becomes the caller's 400. A malformed
entry fails your plugin's load, not a request an hour
later. They appear in api.md and in
GET /api/plugins.
If you need a permission the catalog does not have, add it with a
description — PERMISSION-DOCS in
domain/authz/permissions.rkt; an undescribed permission is
a load error.
(provide init!)
(define (init!) (register-job-kind! "x.acme.lane-report" lane-report))
;; handler: (conn principal payload) -> jsexpr
init! runs at load with full SDK access, and this is the
documented route. The name must be
x.<plugin-id>.<name> — enforced while your
plugin loads, so nothing can take a core kind's name or another
plugin's. Enqueue with
POST /api/jobs {"kind":…,"payload":…}; watch it on
GET /api/jobs.
Your kind is an ordinary job: the enqueuing team and user, the per-team concurrency cap, quota admission, the org gate on whatever your handler touches, cancellation and a lease that re-queues it if the worker dies. None of that is inherited from the plugin — it comes from the row.
For multi-step work, publish a workflow instead
(POST /api/workflows): a validated spec whose steps are
jobs, with fan-out and choice. See workflows.md.
Separate from the product and skinnable on its own: Tier
A the built-in themeable shell, Tier B your
own bundle in plugins/<id>/landing/ over the
window.Telemachus.beta SDK, Tier C a
sandboxed HTML template. All three go through one anti-abuse gate, and
the funnel's fields, copy, theme and translations live in an experience
document an operator edits in Admin → Beta. See design/beta-onboarding-experience.md.
plugins/integrator-demo/ is the whole of the above in
one directory, and it is the file to copy:
plugins/integrator-demo/
plugin.json id, name, version, description, entry
main.rkt a tool, two routes, a namespaced job kind via init!
bundle/index.html a screen that calls its own route and wears the theme
Try it on a running instance:
curl "$BASE/api/x/integrator-demo/eta?lane=SIN-LAX" -H "Authorization: Bearer $TOKEN"
# {"lane":"SIN-LAX","days":18}
curl -X POST $BASE/api/jobs -H "Authorization: Bearer $TOKEN" \
-d '{"kind":"x.integrator-demo.lane-report","payload":{"lanes":["SIN-LAX","HKG-LAX"]}}'
open "$BASE/api/x/integrator-demo/bundle/" # after signing into the consoletest/server-smoke.sh asserts every one of those, so the
example cannot rot silently.
nix develop, then
raco make server/main.rkt — Nix is the toolchain.plugins/integrator-demo/ to
plugins/<your-id>/ and rename the id in
plugin.json. git add it: Nix
only sees tracked files.… — 1 tool(s), 2 route(s), 1 job kind(s) +init).racket cli/telemachus-docs.rkt render and commit
docs/reference/ — CI fails on drift.