Your first container demo

A demo with a server: a copy of its own for every viewer.

On this page

A container demo is a server: an API and the apps it serves, running as one process. Every viewer link gets a copy of its own, started when the viewer opens the room and seeded fresh, so one prospect's clicks never reach another's. This page builds a small one end to end: a Node server with two apps behind one origin, a Dockerfile that keeps to the build contract, a reset control, a two-step tour, a rehearsal on your machine, and a publish. Container demos has the full contract.

What you will build

Harbour Cafe is a demo for a café's ordering system. It has two apps:

  • Floor, for staff taking orders at the table, framed as a phone, at /floor/.
  • Kitchen, a screen on the kitchen wall that shows open orders, at /kitchen/.

Both are served by one server, beside its API at /api/. That is the shape the room needs: it loads one origin, and shows each app by its path.

You need Node 24, gitleaks, and Docker running. The rehearsal builds your image and runs it on the platform's own runner, locally, so it cannot run without Docker.

The server

The whole demo is one file, src/server.js, using Hono. Its data is mock data, held in memory and seeded every time the server starts:

import { serve } from '@hono/node-server'
import { Hono } from 'hono'

// Mock data, in memory, seeded fresh every time the server starts.
const seed = () => [
  { id: 1, table: 4, item: 'Flat white', ready: false },
  { id: 2, table: 7, item: 'Crab sandwich', ready: false },
  { id: 3, table: 2, item: 'Pot of tea', ready: true },
]
let orders = seed()

const page = (title, script) => `<!doctype html>
<html><head><meta charset="utf-8"><title>${title}</title></head>
<body><h1>${title}</h1><ul id="orders"></ul>
<script>
async function draw() {
  const orders = await (await fetch('/api/orders')).json()
  document.getElementById('orders').innerHTML = orders
    .map((o) => '<li>Table ' + o.table + ': ' + o.item + (o.ready ? ' (ready)' : '') + '</li>')
    .join('')
}
${script}
draw()
</script></body></html>`

const app = new Hono()

app.get('/healthz', (c) => c.text('ok'))
app.get('/api/orders', (c) => c.json(orders))

// Demo routes: the room calls these on the viewer's own copy.
app.post('/api/demo/reset', (c) => {
  orders = seed()
  return c.json({ message: 'Back to opening time: three orders on the board.' })
})
app.post('/api/demo/rush', (c) => {
  const next = orders.length + 1
  for (let i = 0; i < 5; i++)
    orders.push({ id: next + i, table: 10 + i, item: 'Bacon roll', ready: false })
  return c.json({ message: 'Five orders just came in from the terrace.' })
})

// Two apps on one origin, each under its own path.
app.get('/floor/', (c) => c.html(page('Floor', '')))
app.get('/kitchen/', (c) => c.html(page('Kitchen', 'setInterval(draw, 2000)')))
app.get('/', (c) => c.redirect('/floor/'))

serve({
  fetch: app.fetch,
  port: Number(process.env.PORT ?? 3000),
  hostname: process.env.HOST ?? '127.0.0.1',
})

Four things in it are what the platform relies on:

  • It listens on PORT and HOST. The platform's runner sets both. The fallbacks are only for running it yourself.
  • /healthz answers 200 once it is up. The runner waits for it before a viewer sees anything.
  • The demo routes answer JSON with a message. When the room runs one on a viewer's copy, it shows that message as done, in your words. A route that fails with an error in its JSON has that shown instead.
  • Its state is in memory. A new copy starts from the seed, so every viewer starts from the same place, and reset puts them back there.

Its package.json names it harbour-cafe, sets "type": "module", and depends on hono and @hono/node-server. Run npm install once, so there is a package-lock.json for the Dockerfile's npm ci.

The Dockerfile

FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
CMD ["node", "src/server.js"]

The platform does not run your image. It takes the /app folder out of it, with the CMD that starts it and the ENV it sets, and runs that on its own runner image, which is Node 24 and nothing of yours. So the Dockerfile's last stage has to build everything into WORKDIR /app, install its dependencies there at build time, and say how to start in an exec-form CMD. A viewer's container has no internet, so nothing can be installed when it starts.

Describe it

In the demo's folder:

pitch init --device app=phone
  Read /Users/you/harbour-cafe
    name "Harbour Cafe" from package.json
    kind container (a Dockerfile)
    health route /healthz
    reset control POST /api/demo/reset (src/server.js)
    scenario POST /api/demo/rush (src/server.js)
  container, with 1 app(s):
    app          /            phone   (from --device)
  Wrote pitch.json (1 app, container) and pitch.README.md, which explains it and shows a story's shape.
  Wrote PITCH.md, the guide for a coding agent: the rules, the build contract and the loop.
  Made AGENTS.md, one line pointing at PITCH.md.
  There is no story yet: https://getroomi.com/docs/story
  Next: `pitch validate`, then `pitch deploy` to rehearse. Nothing leaves this machine without --commit.

pitch init read the Dockerfile as a container build, found the health route, and found the two demo routes in the server's source: the one ending /reset became the reset control, and the other a scenario, a button a viewer can press to drive the demo by hand. A demo route that takes a parameter or reads a body is noted rather than guessed; add it by hand.

It found one app at /, because the two apps are routes on one server rather than separate projects, and it cannot tell that from the files. Without --device it would have asked which device that app is for.

Two apps

Edit pitch.json so it lists both apps, and give the scenario a better label. The finished file, with a story added in the next step:

{
  "$schema": "https://getroomi.com/schema/pitch.json",
  "name": "Harbour Cafe",
  "kind": "container",
  "health": "/healthz",
  "apps": [
    { "id": "floor", "name": "Floor", "path": "/floor/", "device": "phone", "default": true },
    { "id": "kitchen", "name": "Kitchen", "path": "/kitchen/", "device": "screen" }
  ],
  "controls": {
    "reset": "POST /api/demo/reset",
    "scenarios": [
      { "id": "rush", "label": "Terrace rush", "call": "POST /api/demo/rush" }
    ]
  },
  "story": [
    {
      "id": "floor",
      "title": "Orders on the floor",
      "say": "Staff take orders at the table on a phone. Every open order is on one list.",
      "app": "floor",
      "run": ["POST /api/demo/reset"]
    },
    {
      "id": "rush",
      "title": "The terrace fills up",
      "say": "Five orders arrive at once, and the kitchen screen has them before anyone walks over.",
      "app": "kitchen",
      "run": ["POST /api/demo/reset", "POST /api/demo/rush"]
    }
  ]
}

Each app gets a tab in the room, drawn in the frame its device names. The app marked default opens first.

Add a tour

The story above is a two-step guided tour. Each step says what to notice, puts an app on the stage, and first runs its calls on the viewer's own copy. The second step resets before it runs the rush, so it works the same whether the viewer arrives at it from step one or jumps straight to it from the tour's outline. Writing the tour covers writing one well.

Check it

pitch validate
  ok   manifest: pitch.json validates (container, 2 apps, a 2-step story)
  Nothing was built or sent. `pitch deploy` rehearses the whole deploy.

For a container build, pitch validate also reads the Dockerfile for the contract's lines and notes what looks wrong: a last stage that does not build in /app, no CMD, a CMD that installs packages, or a Node version other than 24. It does not build anything; the rehearsal does.

A mistake is reported with the entry that explains it. A step pointed at an app that does not exist:

  pitch.json does not validate: 1 problem
    story[1].app: step "rush" happens in app "kitchn", which apps does not declare
      https://getroomi.com/docs/pitch-json#field-story-app

Rehearse

pitch deploy
  ok   manifest: pitch.json validates (container, 2 apps, a 2-step story)
  ok   secrets: gitleaks scanned the project and found nothing
  ok   build: docker build --platform linux/amd64 -t pp-build/harbour-cafe:latest
  ok   bundle: /app and how it starts: node src/server.js
  ok   secrets: gitleaks scanned the bundle and found nothing
  ok   run: the platform's runner answered /healthz (unpacked in 403 ms, healthy 2983 ms after starting)
  ok   story 1/2 "Orders on the floor": POST /api/demo/reset — "Back to opening time: three orders on the board."
  ok   story 2/2 "The terrace fills up": POST /api/demo/reset, POST /api/demo/rush — "Five orders just came in from the terrace."

  What would go live: project "harbour-cafe" at https://api.getroomi.com
    Harbour Cafe — container, health /healthz
    artefact 335.6 KB (343664 bytes)
    apps:
      floor        /floor/      phone   Floor  (opens first)
      kitchen      /kitchen/    screen  Kitchen
    starts with: node src/server.js
    controls:
      reset        POST /api/demo/reset
      rush         POST /api/demo/rush  "Terrace rush"
    story:
       1 floor        in floor, 1 call  "Orders on the floor"
       2 rush         in kitchen, 2 calls  "The terrace fills up"

  Rehearsal only: nothing has left this machine. `pitch deploy --commit` publishes exactly this.

In order, the rehearsal:

  1. checks pitch.json, as pitch validate does;
  2. scans the project for secrets with gitleaks;
  3. builds your Dockerfile for linux/amd64, which is what the platform runs, whatever your machine is;
  4. copies /app out of the image as the bundle, and reads its CMD and ENV as how it starts;
  5. scans the bundle, and the environment it will start with, for secrets;
  6. builds the platform's runner image and starts it on your machine, hands it the bundle through the same control port a viewer's copy uses, and waits for your health route to answer through it;
  7. runs each story step's calls on that copy, in order, and prints the demo's own words for each;
  8. shows what would go live.

The runner waits up to 90 seconds for the health route. If a story step fails on the rehearsal's copy, the deploy stops there, because a viewer's tour would stop at the same step. The rehearsal may call only the copy it started on your machine: any other network call from it is refused.

Publish

pitch deploy --commit

This repeats the rehearsal, then uploads the bundle it checked, registers the build with its manifest, uploads any pages pitch.json lists, and makes the build live. The platform stores the bundle as it is and never unpacks it.

On a paid plan, which has private links, it ends:

  ok   project: created "harbour-cafe"
  ok   upload: 335.6 KB
  ok   build: registered 9d2f41c7-6a0b-4b5e-8f13-7c4e2a9b8d06, with the story's rehearsal (2 steps)
  ok   live: build 9d2f41c7-6a0b-4b5e-8f13-7c4e2a9b8d06

  Live. The room is private: it opens only through a viewer’s own link.
  `pitch link <name>` makes one; `pitch open` shows you the room as a viewer sees it.

See it with pitch open, or Preview as viewer on the project in the app, and send it with a private link (Sharing a private link). The project's Overview shows the tour with each step's rehearsal: both steps make calls, so both read Passed.

On Free, which has no private links, the last lines say instead that the room is a draft only your workspace can open, and that listing it on the community is how to show it to anyone (Quickstart). Free runs static demos only, so a container project's room there opens with its pages and materials, and its demo frame says the live demo is not available on this plan. On a paid plan, a listed project's container demo is shared by everyone at its public address, five at a time.

When someone opens a link, the room starts a copy of the live build for that link while it draws the page, so the cold start passes while they read. The stage says Starting the demo… until the copy answers its health route. Everyone who comes in through the same link shares that copy: the phone they hand the demo to with On your phone, and anyone they forwarded the link to. Another link, or a new build, gets a new copy.

How long a copy lives:

When What happens
The tab is visible and in use The room keeps the copy awake with a heartbeat every 60 seconds.
The tab has been hidden or untouched for 10 minutes The room stops keeping it awake.
Nothing has reached the copy for 3 minutes The container stops. The room says The demo paused while you were away. and offers Resume the demo, which starts a fresh copy from the seed.
A copy has run for 2 hours It stops, however busy it is, and the viewer is offered the same resume.
A copy is not ready 90 seconds after starting It is stopped, and the room says The demo could not start. with Try again.

An open stream, such as server-sent events on a kitchen screen, does not keep a copy awake on its own: the heartbeat decides whether anyone is still watching.

When it is full

Each project can have a set number of copies running at once, which is how many viewers can be in a container demo together:

Plan Copies at once, per project
Free None: static demos only
Pro 5
Team 10
Organisation 50

A viewer past that number sees This demo is full right now, and it counts as a capacity bounce on your plan panel. If the platform as a whole is busy, the room says The demo is starting — high demand, one moment., tries again every 5 seconds for a minute, then offers Try again.

When it goes wrong

The rehearsal says What to do
Docker is not running Start Docker and run pitch deploy again.
the image's WORKDIR is ... The last stage of the Dockerfile needs WORKDIR /app.
the image has no CMD Add an exec-form CMD, such as CMD ["node", "src/server.js"].
the runner could not start the build It prints the build's last 20 lines of output. Usually the server crashed, is not listening on PORT, or its health route never answered 200.
FAIL story A step's call was refused. It usually needs something set up first: add that call to the start of the step's run.
FAIL secrets Remove the secret from the project or the image, then rotate it.

Troubleshooting has more.

Next