Skip to content

Tools, jobs and the prompt

The cheapest extension point: a fragment injected into the system prompt while the app is installed.

prompt: (ctx) => {
const trade = ctx.settings.trade;
if (!trade) return null;
return `### Bill of quantities\n\nThis company does ${trade}. Keep only its items…`;
}

It runs on every request, so it must be synchronous and cheap — no network, no database — and it receives organizationId and the validated settings. Return null when there’s nothing worth saying: an empty instruction is tokens the customer pays for.

A function the model can call mid-conversation, with the JSON Schema of its arguments.

tools: [
{
name: "gold_price",
description: "Today's gold price for a fineness, net of fees.",
parameters: {
type: "object",
properties: { fineness: { type: "string", enum: ["750", "585"] } },
required: ["fineness"],
},
async handler(args, ctx) {
const quote = await ctx.shared.get<number>("eur-per-gram");
return { pricePerGram: quote };
},
},
]

The host qualifies the name with the app’s id, runs the handler with a timeout, and returns the result to the model. A tool that throws doesn’t take the conversation down: the failure is isolated and the model is told.

This is the part worth reading twice. A tool can attach the line that follows from its result:

import { withProposedLine } from "@prezzando/plugin-sdk";
return withProposedLine(
{ pricePerGram: 71.4 },
{ description: "Gold 750 purchase", qty: 12, unit: "g", price: 71.4, vatRate: 0,
vatNote: "Regime del margine" },
);

The host sets the line aside and hands the model an opaque reference. When the model fills in the quote and reports that reference, the host puts the real numbers back in place of whatever the model wrote. The model stops doing arithmetic and starts copying a string, which it is good at.

Note what the line does not decide: how totals are computed. The app knows the nature of the operation it priced and says so with vatRate and vatNote; the arithmetic stays in the core, the one place where tax behaves the same way for everyone.

withToolCard attaches a small card the chat renders under the answer — a headline, a few rows, a footnote. Same idea as the settings: you declare what to show, the host draws it.

Work repeated every day, once for the whole platform and not once per customer.

jobs: [
{
name: "gold-fix",
label: "Daily gold quote",
dailyAt: "08:00",
timeZone: "Europe/Rome",
async handler(ctx) {
const data = await ctx.http.fetchJson<{ price: number }>("https://api.metals.dev/…");
await ctx.shared.set("eur-per-gram", data.price);
},
async summary(ctx) {
const price = await ctx.shared.get<number>("eur-per-gram");
return price ? `In force: ${price} €/g` : null;
},
},
]

It exists for data that is the same for everyone and costs something to fetch. Downloading it per tenant would multiply by the number of customers a call with one single answer — and with a metered source, that difference is the bill.

The context of a job is deliberately narrower than a tool’s: no organisation, no settings, no per-tenant storage. What it produces goes in shared, where every organisation then reads it. The host guarantees one run a day even with several API replicas and retries a failure instead of skipping the day, but the handler must be idempotent anyway: a retry after a timeout can find the work already done.

summary is the line the panel shows about how the job is doing. It’s on the app because only the app knows what it downloaded — the host holds the dates, not the meaning — and it must never throw: it’s called to draw a panel, not to make a price.