Skip to content

Extend: Views — dashboard tiles, cards, and agent tools

Lane 5 of Extend Tinbox — config-registered UI surfaces (plugin.ui slots) plus tenant-authored agent tools (plugin.agentTools), both deploy-time and declarative.

A View is how you reshape the app surface: render your own component into a named slot, and give the assistant the query behind it. Lane 5 has two halves — the UI side (plugin.ui, where a component renders) and the data side (plugin.agentTools, a function the assistant can call). Both are deploy-time and declarative: the engine ships a component id and the SPA owns the code, so there is no runtime code upload. (A runtime, D1-stored view builder is a separate cloud-track feature, not this lane.)

Here is a plugin that does both — a "sourced this week" dashboard tile backed by an agent-callable query:

import { definePlugin } from "tinbox/cf/plugin";
import { agenticSkill } from "tinbox/cf/skills";
import { z } from "zod";

export default function weeklyPulse() {
  return definePlugin({
    name: "weekly-pulse",
    version: "1.0.0",

    // The DATA side — a tool the assistant, MCP, and CLI can call. Same mechanism
    // as a top-level agentic skill; listing it here reads as "this plugin's tools."
    agentTools: [
      agenticSkill({
        id: "weekly-signups",
        label: "Weekly signups",
        description: "Count new audience signups in the last 7 days.",
        inputSchema: z.object({ audienceId: z.string() }),
        run: async ({ audienceId }, ctx) => {
          // your query — return model-facing data
          return { audienceId, added: 42, window: "7d" };
        },
      }),
    ],

    // The UI side — render a component id into a named slot. The component must
    // already exist in the deployed UI bundle (a builtin, or one you vendored).
    ui: [
      {
        slot: "dashboard",
        component: "info-note",
        title: "Weekly pulse",
        props: { body: "42 new signups this week. Ask the assistant for the breakdown." },
        visibility: "ui",
      },
    ],
  });
}

Compose it into config.plugins in tinbox.config.ts and redeploy. The tool shows up wherever the assistant runs (chat, /mcp, CLI); the card renders on the dashboard.

The UI side — plugin.ui

Each entry declares that a component should render into a named app slot:

ui: [
  { slot: "dashboard", component: "revenue-tile", visibility: "ui", props: { period: "30d" } },
]
  • slot — one of the named app slots: dashboard, contact-card, inbox-card, message-actions, thread-actions. Keep this list in mind — an unknown slot fails validation at definePlugin time.
  • component — the @tinbox/ui-style component id the SPA resolves through its registry. The worker never imports your React; it carries an id. An unknown id renders nothing — a surface pointing at a component this build doesn't have fails soft, it never crashes the page.
  • visibility — ui (human only), model (assistant context only), or both (default). Only non-model surfaces render in a slot.
  • props — static, JSON-serializable configuration passed to the component. For data that must be fetched live, back the component with a plugin routes endpoint (lane 4) or an agentTools query (below) rather than static props.

The composed manifest is read by the SPA via surfaces.list; the slot host renders each entry through the id→component firewall. The shipped info-note component is a titled note card you can point at to prove a slot renders (pass props.body); add your own components to your vendored copy of the surface registry.

The data side — plugin.agentTools

agentTools is a semantic alias for contributing agentic skills — functions the assistant (and MCP, and the CLI) can call as tools, without forking the core tool set. Author each with agenticSkill():

import { agenticSkill } from "tinbox/cf/skills";
import { z } from "zod";

agenticSkill({
  id: "order-lookup",
  label: "Order lookup",
  description: "Look up an order by number and return its status.",
  requiresEnv: ["SHOP_TOKEN"],          // gated off until the secret is set
  requiresHosts: ["api.myshop.com"],     // ctx.fetch host allowlist
  inputSchema: z.object({ orderId: z.string() }),
  run: async ({ orderId }, ctx) =>
    (await ctx.fetch(`https://api.myshop.com/orders/${orderId}`, {
      headers: { authorization: `Bearer ${ctx.env.SHOP_TOKEN}` },
    })).json(),
});
  • The inputSchema (Zod) types run's input and validates every call.
  • requiresEnv gates the tool off until its secret is configured; requiresHosts pins ctx.fetch to an allowlist.
  • Default reversibility: "reversible" lets autopilot call it freely; mark it "acting" and it's held for approval.
  • Whether you list a tool under a plugin's agentTools or a plugin's skills, or as a top-level config.skills entry, it merges the same way — your own config.skills win on an id conflict.

What's shipped vs what's next

  • Shipped (config): plugin.ui slot registration, the surfaces.list manifest, the dashboard slot render point, and plugin.agentTools. All deploy-time.
  • Not this lane: a runtime, in-app "view builder" that stores a view spec in D1 (so a tenant who can't redeploy still builds views). That's deferred to the cloud track; this lane is the code-and-config path.

Which lane instead?

  • To classify inbound mail and render a card on the message → Parsers (lane 1) — a reactive skill's component verdict.
  • To run a webhook, cron, or custom route behind your view → Plugins (lane 4).
  • To send mail from another app → Scripts (lane 3).

Back to the six lanes.