Skip to content

Dashboard ​

@chamvi/ui is a dashboard for the agents of an app: see what each agent does, start a run, watch runs live, answer approvals, look into agent state and see when cron triggers fire next. It is a prebuilt Vue 3 app (vuecs, the Crimson design) that your own @chamvi/http app serves on the same origin, so there is no CORS and no second deployment.

bash
npm install @chamvi/ui --save

uiRoutes() returns the extra routes for createHttpApp({ routes }):

ts
return createHttpApp({
    engine,
    auth: [staticTokens({
        tokens: [{
            value: process.env.ADMIN_TOKEN!,
            id: 'admin',
            permissions: ['*'],
        }],
    })],
    // the dashboard at /ui, signed in with a pasted bearer token
    routes: uiRoutes(),
});

Open /ui/ and paste a token; the dashboard opens on the agents overview. Nothing of the dashboard runs on the server but one static file route; every page talks to the API it already has, with the signed-in principal's bearer token.

Pages ​

PageShowsNeeds
AgentsA card per agent: its triggers (GitHub events, cron expressions, hook paths, http), running and waiting runs, the next cron fire, a 24 h activity strip, the last run, the notifier and the tools that need approval; a name filter. The landing page after sign-in.run:read
AgentThe agent's budget, notifier and approval tools, Start run (with an http() trigger: a JSON payload sent to POST /agents/:name/runs as you) and View all runs, and four tabs: Overview (runs, failures, waiting approvals and tokens, recent runs, its waiting approvals, its triggers with route, hook or next fire), Runs (filtered by status), State (key-value entries and collections, read-only) and Schedules.run:read; agent:<name>:run to start; state:read for State
RunsEvery run, newest first (GET /runs?sort=-createdAt), filtered by agent and status in the address bar; running and waiting counts, failures and tokens of the last 24 h; tokens per run. Refreshes every 5 s.run:read
RunStatus, budgets (steps, tokens, time), the trigger envelope and the step timeline (llm, tool and wait steps, inputs and outputs collapsed). Live over the run's SSE stream, including the streamed text of the step in progress. A cancel button with a confirmation.run:read or agent:<name>:run; run:cancel to cancel
ApprovalsEach waiting run with the tool, its input, the expiry countdown and a reason field; Approve or Deny (POST /runs/:id/approve). The sidebar shows the count.run:read to list, agent:<name>:approve to answer
SchedulesThe cron() triggers per agent with the next fire time in the trigger's time zone.run:read

Agent names link to the agent page from every other page. The 24 h figures come from the newest 100 runs (of the agent on its page, of the whole app on the overview and the runs page), since the API has no time filter: more runs than that in a day show 100+. Only tools that need an approval are listed, because an agent may build its tool set per run.

A principal without the grant a page needs sees a notice naming it; the rest of the dashboard keeps working. An approval link from a notification needs read access to its run (run:read or agent:<name>:run) besides agent:<name>:approve; only a resume-token link (#token=, below) answers with agent:<name>:approve alone.

Sign-in ​

  • Token (default): a static bearer token of staticTokens(). GET /me checks it before it is kept, in the tab's sessionStorage only; sign-out removes every chamvi. key but a pending approval link. Only a 401 drops a stored token: a reload while the API restarts or rate limits keeps it, and the next navigation tries again.
  • authup: auth.authup adds "Continue with authup", the OIDC code flow with PKCE through @authup/client-web-kit, whose tokens are kept in the tab's sessionStorage (chamvi.authup.*), never in cookies. The authup client needs <origin><uiBase>/login/callback as a redirect URI. Pair it with the app's authup() authenticator. auth.tokens: false hides the token form (then auth.authup is required).
ts
return createHttpApp({
    engine,
    basePath: '/api',
    auth: [authup({ verifier })],
    routes: uiRoutes({
        // served at /api/console/, the API under /api
        basePath: '/console',
        auth: {
            tokens: false,
            // the authup client needs <origin>/api/console/login/callback as a redirect URI
            authup: { url: 'https://auth.example.com/api', clientId: 'chamvi' },
        },
    }),
});

A revoked token or an expired authup session answers 401: the dashboard refreshes the authup session once, else signs out and returns to the sign-in page with the page it was on as the way back.

A notifier links a person straight to an approval by run, without a token: <origin><uiBase>/approvals?run=<runId>&approval=<approvalId> (approval is optional and then means the approval the run waits on when the page opens). A signed-out visit goes through sign-in, the token form or the authup round-trip, and comes back to the same URL. Signed in, the dashboard scrolls to that run's approval card, highlights and focuses it; Approve and Deny answer through POST /runs/:id/approve as the signed-in principal, so the link is only as powerful as the person who opens it. The page reads the run with GET /runs/:id (run:read or agent:<name>:run) and follows it with the inbox poll:

The runThe page shows
waits on that approvalthe highlighted card
waits on another approvala notice linking the run; the new approval is listed in the inbox
no longer waits (answered, finished) or the approval expireda notice linking the run
waits, but the principal lacks agent:<name>:approvea 403 notice naming the grant, and the card without Approve and Deny
is not readable with the principal's grantsa 403 notice
does not exist"not found"

The resume token reaches a notification only when a notifier is set up to hand it to a trusted channel (the OpenClaw notifier's resumeTool: true). The older token link still works: <origin><uiBase>/approvals#token=<resume token>. Use the fragment: a browser never sends it, so the token stays out of the server's and every proxy's access log (?token= works too, a repeated one included, but travels in the request line). Before anything renders, the dashboard moves the token out of the address bar into sessionStorage (so the history, a sign-in redirect and the Referer never carry it; every answer is also sent with referrer-policy: no-referrer), survives the sign-in round-trip and a sign-out (a principal without the grant can hand over to one with it in the same tab), and answers through POST /resume with that token. An expired or already used token reads as "already answered". While such a token is pending in the tab it takes the page: a token-less link opened meanwhile in the same tab shows a notice naming both runs and a button that discards the pending token, after which the link opens. The inbox itself never sees a resume token: it answers by approval id, and a run that moved on to another approval answers 409.

Serving ​

OptionDefault
basePath'/ui'The dashboard's path under the app's basePath, lower case
auth.tokenstrueOffer the token form
auth.authupnone{ url, clientId, realmId? }
rootthe package's dist/appAnother build folder
  • Every GET below basePath answers a file of the build, index.html for any other path without an extension (the client routes), or 404. Hashed files under assets/ are cached for a year (immutable), the rest no-cache.
  • index.html gets <base href="<uiBase>/"> and the config (apiBase, uiBase, the sign-in modes) as a JSON block, so the same build works under any basePath.
  • Paths are decoded once and refused on .., ., empty segments, backslashes, NUL and double encoding: nothing outside the build is read.
  • Every answer carries a Content-Security-Policy (default-src 'self', scripts only from the build, connect-src and form-action add the authup origin, frame-ancestors 'none'), x-content-type-options: nosniff, x-frame-options: DENY and referrer-policy: no-referrer. Fonts and icons are part of the build; nothing loads from a CDN.
  • The route checks no credentials (the build holds no secret) and skips the rate limiter.

Behind a proxy that mounts the app under a prefix, set the app's basePath; the dashboard follows it. Invalid options throw InvalidAgentDefinitionError; a missing build is reported on the first request (500 and onError), so the build may land later, e.g. in a container image.

Live updates ​

The runs list and the inbox poll every 5 s. The run page follows GET /runs/:id/stream with a fetch-based reader (an EventSource cannot send the bearer header): after a dropped connection it reconnects with the last step index as Last-Event-ID (1, 2, 5, then every 10 s, or the Retry-After of a 503 from the stream cap), so a step is never lost or shown twice. Streamed text belongs to the model step in progress and gives way to the step once it is stored.