The showman-service is Binions’ built-in web server and template renderer — a tiny content management system that publishes pages and dashboards straight from your playbook data. Instead of standing up a separate web stack to show the results of your automations, you point a playbook step at showman and it serves an HTML page over HTTP. It handles static pages, uploaded assets (CSS, JavaScript, images), and dynamic pages built from a playbook by filling in a template with live data. The same server also runs the platform’s inbound HTTP gateway: a request to /in/<route> becomes a Fact.Http.Received trigger, with optional synchronous replies and per-route signature checks.
The showman-service is one of Binions’ background services — we call them daemons. You never call it directly; you describe what to publish in a playbook, and showman does the serving. New to the platform? Start with Core concepts.
Most automations end by showing a result: a status board, a report, a one-page summary of what just happened. The showman-service is the daemon that turns that result into a real web page you can open in a browser. It gives you three publishing layers, all driven from playbooks:
/status. Showman keeps a small registry of these pages and serves their bytes exactly as written — inline <script> blocks included./assets/<name> so your pages can reference it.Two more capabilities round it out. Pages can be registered as live (show.register_page with live:/channel:) and are then served over WebSocket/SSE with playbook push. And the gateway works in the other direction: requests on /in/<route> become Fact.Http.Received events that can trigger any playbook — with per-route HMAC signing available for routes that face the internet (more on signing below). See HTTP edge & realtime for the full gateway story.
Showman runs its own lightweight HTTP server bound to the local host (127.0.0.1:9099). The platform also publishes it on a dedicated, authenticated edge entry point — the Showman hub on :8446 — so operators can open its pages over HTTPS without the daemon being exposed directly; the full port map is in Network requirements. To route a page to the main edge for your own users instead, expose it through traefiklinker-service — see also HTTP edge & realtime.
The pattern. A scheduled job, an inbound email, or a webhook produces some data; a playbook hands that data to showman; showman renders it into a page. Your dashboards stay in sync with your automations without any extra moving parts.
| What it is | A built-in HTTP page & asset server with a template renderer |
| Playbook prefix | show. — e.g. show.render_template |
| Operations | 12 — 5 page management, 2 cache, 2 asset, 1 status, 2 template |
| Template language | Jinja-style templates (loops, filters, conditionals; tojson for script-safe data) |
| Serves | HTML pages — inline <script> served byte-identical — and static assets as real files |
| Inbound gateway | /in/<route> → Fact.Http.Received triggers, with optional per-route HMAC signing |
| Reacts to | Data-change facts — clears its cache so pages refresh automatically |
| Licensing | One of the 13 binions in a set — £1 each, £13 per host (free for one set on one host, non-commercial) |
You drive showman from playbook steps. Each step names an operation with the show. prefix and passes its arguments under with:. Behind the scenes that becomes an Action.Showman.* event, and showman replies with a matching Fact.Showman.* event your playbook can wait on.
| Operation | What it does |
|---|---|
show.register_page | Register a page at a URL path, from inline HTML, a fetched file, or a template. |
show.update_page | Replace a registered page’s content — inline, fetched from a URL, or re-rendered from a registered template with fresh data — and refresh its cache entry. |
show.unregister_page | Remove a page from the registry. |
show.list_pages | List every registered page with its path, size, and source. |
show.set_index_menu | Define the navigation menu shown on the hub index (label, link, optional icon and group). |
show.register_template | Store a named, reusable template (Jinja-style source). |
show.render_template | Render a registered template with supplied data and publish it at an output path. |
show.upload_asset | Write a static asset (CSS, JS, image, font) as a real file in the assets directory — from inline text, base64 binary, or a fetched URL. |
show.delete_asset | Remove an uploaded asset’s file from the assets directory. |
show.invalidate_cache | Drop one cached page (by path) or clear the whole cache. |
show.preload_cache | Warm the cache by reading a list of pages from disk ahead of time. |
show.status | Report a summary: page count, asset count, requests served, last error. |
A minimal step that registers a static page from inline HTML looks like this:
steps:
- id: publish_welcome
run: show.register_page
with:
name: welcome
path: /welcome
content_inline: "<h1>Hello from Binions</h1>"
Paths must start with / and may not contain ..; names are limited to letters, digits, and - _ . — both are validated before anything is served. When you register a page, exactly one source must be set: content_inline, content_url (a file:// or http(s):// location to fetch), or template. Registered pages are real files too: a page at /status materializes as pages/status/index.html under the pages directory, and a request for /status is redirected (307) to /status/. Clearing a cached page after you change its contents is just as simple:
steps:
- id: refresh
run: show.invalidate_cache
with:
path: /welcome
reason: "content updated"
Every asset you upload materializes as a real file in the served assets directory — there is no asset that exists only as a registry entry. show.upload_asset writes the file and serves it at /assets/<name>; show.delete_asset removes the file again. Exactly one content source must be set: content_inline for text (CSS, JavaScript), content_b64 for binaries (images, fonts), or content_url to fetch the content from a URL.
steps:
- id: styles
run: show.upload_asset
with:
name: board.css
mime_type: "text/css"
content_inline: |
body { font-family: sans-serif; }
table { border-collapse: collapse; }
One rule matters: the asset name must carry a real file extension — the Content-Type the file is served with derives from it, so board.css arrives at the browser as CSS and logo.png as an image. Because assets are ordinary files on disk, everything your pages link to survives restarts and is served exactly as uploaded.
Static pages are perfect for fixed content. For anything that changes — a status board, a daily report — use a template. A template is written once in a familiar Jinja-style syntax (the minijinja engine, the same one that powers templated mail in mailbox-service) that supports variables, loops, filters, and conditionals. You register it by name, then render it as often as you like with different data.
Register a template (here, a small card that lists rows):
steps:
- id: define_card
run: show.register_template
with:
name: deploy-status-card
template_source: |
<h2>Deployments</h2>
<table>
{% for row in rows %}
<tr><td>{{ row.service }}</td><td>{{ row.version }}</td></tr>
{% endfor %}
</table>
Registered templates are also written to disk, so they survive a restart — showman reloads them automatically when it starts. Rendering then fills the template with data and publishes the result at the path you choose:
steps:
- id: render_board
run: show.render_template
with:
template: deploy-status-card
data:
rows: ${prev.rows}
output_path: /deploy-status
Two details are worth knowing. First, data is the template context: the template above references {{ rows }} directly, never {{ data.rows }}. Second, output_path is a page path with a leading /; when it has no file extension, the rendered page is served with a .html suffix — here, at /deploy-status.html. The result is cached in memory for fast delivery. Templates use strict handling of undefined variables: if your data is missing a field the template expects, the render fails loudly with a clear error rather than quietly producing a blank space — so a broken dashboard never slips out unnoticed.
Self-hosted, like everything else. Pages, assets, and templates all live on your own host. Nothing is uploaded to a vendor cloud, and the HTTP server listens on the local machine until you deliberately expose it through the HTTP edge.
show.update_page accepts exactly one of three content sources: inline content, a content_url to fetch — or a {template, data} pair that re-renders a registered template at the page’s own registered path and refreshes the hot cache in the same stroke. The third form is what keeps a live page live: the page keeps its URL, and the refresh playbook never has to repeat an output path — it just names the page and the template.
name: live-page-template-refresh
description: |
Business — every 5 minutes: query the latest metrics and re-render the
registered ops-board template AT the page's own path.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: ops-board-refresh
steps:
- id: rows
run: database.query
with:
table: metrics
order_by: at
order_dir: DESC
limit: 50
- id: rerender
run: show.update_page
with:
name: ops-board
template: ops-board
data:
rows: ${steps.rows.rows}
Here name: ops-board is the registered page and template: ops-board the registered template — register both once at provisioning time, then this one playbook keeps the page fresh forever.
Your pages can carry real JavaScript. Inline <script> markup in registered pages and in rendered templates is served byte-identical — showman never rewrites or strips it. That makes self-updating dashboards straightforward: upload a script as an asset, embed live data in the page, and let the browser do the rest. Two rules keep the data injection safe:
tojson inside scripts. The {{ data | tojson }} filter renders a value as JSON that is safe inside a <script> block: < is unicode-escaped and quotes stay real quotes. Plain {{ ... }} output remains HTML-autoescaped — exactly what you want in markup, but it would mangle values placed inside JavaScript.${...} out of inline JavaScript. The playbook engine claims every ${...} in a playbook for its own interpolation, so a JavaScript template literal would be swallowed before the page ever ships. Concatenate strings instead ('total: ' + total).Here is the whole pattern end to end — a provisioning playbook uploads the script, registers the template and sets a refresh cadence; a scheduled playbook re-renders the page with fresh rows:
name: register-ops-dashboard
description: Provisioning — the JS asset, the dashboard template, the cadence.
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: js
run: show.upload_asset
with:
name: dash-total.js
mime_type: "text/javascript"
content_inline: |
window.addEventListener('DOMContentLoaded', function () {
var rows = window.DASH_DATA;
var total = 0;
for (var i = 0; i < rows.length; i++) { total += rows[i].value; }
var el = document.getElementById('total');
el.textContent = 'total: ' + total.toFixed(2);
});
- id: tpl
run: show.register_template
with:
name: ops-dash
template_source: |
<html><head><title>Ops dashboard</title>
<script>window.DASH_DATA = {{ rows | tojson }};</script>
<script src="/assets/dash-total.js"></script></head>
<body><h1>Metrics</h1><div id="total"></div>
<table>{% for r in rows %}
<tr><td>{{ r.label }}</td><td>{{ r.value }}</td></tr>
{% endfor %}</table></body></html>
- id: cadence
run: scheduler.register_schedule
with:
name: dash-refresh
interval_seconds: 300
---
name: refresh-ops-dashboard
description: Business — on every tick, query and re-render with fresh rows.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: dash-refresh
steps:
- id: rows
run: database.query
with:
table: metrics
limit: 100
- id: render
run: show.render_template
with:
template: ops-dash
data:
rows: ${steps.rows.rows}
output_path: "/ops-dash"
Note how the script sticks to string concatenation, and how {{ rows | tojson }} hands the query rows to the browser as a JavaScript value while the {{ r.label }} cells in the table stay HTML-escaped. Because /ops-dash has no file extension, the rendered dashboard is served at /ops-dash.html.
showman also hosts Binions’ Model Context Protocol server at POST /mcp, letting an AI assistant discover and call selected playbooks as tools. Each tool is a named entry in the [mcp] config that maps to a playbook route; a tool call becomes a Fact.Http.Received event that triggers that playbook, and the playbook’s reply is returned as the tool result. The endpoint exposes nothing until you list tools, so an agent is confined to the playbooks you publish. An agent can also be granted a small set of built-in tools to deploy and manage playbooks of its own, behind a separate key and a deny-list. Full walkthrough: MCP server.
The showman-service reads a small TOML file. You point it at a pages directory and an assets directory, choose the address its HTTP server listens on, and tell it where templates live. The defaults are sensible — most installs only adjust paths and toggles.
[redis]
host = "127.0.0.1"
port = 6401
password_file = "/opt/binions/showman-service/secrets/redis.password"
[healthcheck]
listen_addr = "127.0.0.1:9111"
[server]
listen_addr = "127.0.0.1:9099"
pages_dir = "/opt/binions/showman-service/pages"
assets_dir = "/opt/binions/showman-service/assets"
templates_dir = "/opt/binions/showman-service/templates"
enable_analytics_emit = true # emit a fact for each page served
enable_error_emit = true # emit a fact on 4xx/5xx responses
max_pages = 100 # cached pages before oldest is evicted
Field by field:
server.listen_addr — the local address the HTTP server binds to. Keep it on the loopback address and let the HTTP edge handle external access.server.pages_dir / assets_dir — where served pages and static assets are stored on disk.server.templates_dir — where registered templates are persisted; loaded on startup.server.max_pages — cache size in pages; once full, the oldest entry is evicted.server.enable_analytics_emit / enable_error_emit — turn the per-response observability facts on or off.redis — the daemon’s own private state store; the password is read from a file, never written inline.healthcheck.listen_addr — the address that answers liveness probes.Per-route HMAC blocks for signed inbound webhooks live in the same file — see the next section. For the full picture of how daemons are configured and where secrets live, see Daemon configuration and Secrets.
Inbound routes often face the public internet — payment providers, SaaS webhooks, partner systems — so showman can require a signature on any /in/<route> before the request is allowed to become an event. Signing is configured per route, one [http_in.hmac.<route>] block per protected route:
[http_in.hmac.stripe-events]
secret_file = "/etc/binions/secrets/showman/stripe-webhook.secret"
header = "stripe-signature" # matched lowercase; default "x-signature"
prefix = "sha256=" # optional prefix stripped from the header value
algo = "sha256" # only sha256 is supported
For every request on a protected route, showman computes an HMAC-SHA256 over the raw request body with the secret from secret_file and compares it — in constant time — against the value of the configured header (matched case-insensitively; the optional prefix, such as sha256=, is stripped first). The secret file is root-owned and read per request, so rotating a webhook secret is just replacing the file — no restart needed. Only sha256 is supported as the algorithm.
A request with a missing or wrong signature is answered with HTTP 401 and recorded as an audit fact — Fact.Http.Rejected with a reason of missing_signature, bad_signature, or hmac_misconfig — and it never becomes a Fact.Http.Received, so no playbook can be triggered by a forged body. Fact.Http.Rejected is itself a trigger event, so a playbook can alert on repeated rejections. Routes without an [http_in.hmac.<route>] entry accept requests exactly as before.
Signature checks are complementary to the guards you can put on the route at the edge: basic_auth or ip_allowlist middlewares on the traefiklinker-service route control who can reach the endpoint, while the HMAC proves the body really comes from the party holding the secret.
Like every Binions daemon, showman speaks in events. You ask it to do something with an Action, and it announces what happened with a Fact. Every event carries a correlation id, so you can trace a request end to end across the platform.
| You send (Action) | Showman replies (Fact) |
|---|---|
Action.Showman.RegisterPage | Fact.Showman.PageRegistered |
Action.Showman.UpdatePage | Fact.Showman.PageUpdated |
Action.Showman.UnregisterPage | Fact.Showman.PageUnregistered |
Action.Showman.RegisterTemplate | Fact.Showman.TemplateRegistered |
Action.Showman.RenderTemplate | Fact.Showman.TemplateRendered |
Action.Showman.InvalidateCache | Fact.Showman.CacheInvalidated |
| Any operation that fails | Fact.Showman.OperationFailed |
Showman also reacts to facts from other daemons. When the data-transfer daemon reports that the published content on disk has changed, showman automatically clears its cache so the next visitor gets the fresh version — no manual step required. And every HTTP response can emit a fact of its own: a Fact.Showman.PageServed on success, or a Fact.Showman.PageError on a 4xx/5xx response, which you can feed into monitoring or alerting playbooks. The inbound gateway adds two more to that observability story: Fact.Http.Received for accepted requests and Fact.Http.Rejected when a signed route turns one away. To understand the shape these events share, see the event envelope reference.
The showman-service installs and runs like every other daemon: a managed system service that starts on boot, restarts on failure, and runs under its own locked-down service account with a watchdog. Install it as part of a Binions set and enable it:
sudo systemctl enable --now binions-showman redis-binions-showman
systemctl status binions-showman
Each daemon pairs with its own small state store, so showman comes with a dedicated companion service. The platform validates the configuration file before the daemon is allowed to start, which means a typo in your TOML is caught immediately instead of after deploy. You can confirm liveness with the daemon’s health endpoint:
curl http://127.0.0.1:9111/healthz
For an at-a-glance summary of how many pages and assets are registered, how many requests have been served, and the last error seen, run a show.status step from a playbook and read the resulting Fact.Showman.StatusReported. General operational guidance lives in Service operations, Health checks, and Monitoring & tracing.
Licensing note. The showman-service is one of the 13 binions in a set. The boot-time licence check can be enabled or disabled in configuration. When enforcement is enabled, an unlicensed binion will refuse to boot — see First boot for how the check works.
* Current packages target 64-bit x86 (amd64). Raspberry Pi and other 64-bit ARM hardware are supported from the 1.0 release.