apps · guide
Build an app
How to extend Vendu with your own tools, records, knowledge and screens without changing its core.
Audience: anyone extending Vendu without touching its core — in-house or partner. Rule this guide exists to enforce: generic capability goes into the platform; anything specific to one business case goes into an app. If you find yourself editing
src/modules/ai_engineorsrc/modules/catalogto ship a case, stop — that is a missing primitive, and it is a bug report on this guide.
1. What an app is
An app is a manifest plus, optionally, an HTTP service you host. The manifest declares primitives; Vendu wires them into the agent and the dashboard. There is no plugin code running inside Vendu.
| Primitive | What it gives you | Runs where |
|---|---|---|
skills | a tool the AI agent can call mid-conversation | your HTTPS endpoint (or sandboxed inline JS) |
resources | your own record type with typed fields + relations | Vendu's DB, isolated per tenant |
knowledge | domain text that lands in the agent's retrieval | Vendu |
flows + events | react to something happening | Vendu |
views | your own screen inside the dashboard | Vendu (table) or your URL (iframe) |
config | per-tenant settings and secrets | Vendu (secrets via vault) |
Everything else — channels, conversations, orders, RAG, escalation, billing — is the platform's job. You do not reimplement it and you cannot break it.
2. The shape of an app
The same four moves cover most cases: your entity, your tool, your knowledge, your screen. This skeleton uses deliberately neutral names — substitute your own domain, and note that no part of it requires a change inside Vendu.
manifestVersion: 1 # note the camelCase — it is a required field
id: vendor:example-app # vendor:slug
version: 0.1.0
name: "Example App"
description: "Skeleton showing every primitive an app can declare."
author: vendor # required
scopes: # required; declare only what the app uses
- read:contacts
- subscribe:ai.escalation_requested
# ── Your own entity. No core change, no migration. ────────────────────────────
resources:
- id: record
name: Record
fields:
- { name: reference, type: string, required: true }
- { name: amount, type: number }
- { name: state, type: enum, enumValues: [draft, active, closed] }
- { name: dueAt, type: date }
exposeToAi: true # the agent may READ these records
allowAiWrite: false # ...and may NOT invent them
# ── Your own tool. The agent calls it like any built-in tool. ────────────────
skills:
- id: estimate
name: Estimate cost
description: >
Returns an exact quote for the customer's request. Always call this when the
customer asks about price — never compute or guess the number yourself.
endpoint: https://api.example.com/estimate
inputSchema:
type: object
required: [reference]
properties:
reference: { type: string }
options: { type: object }
outputSchema:
type: object
properties:
total: { type: number }
currency: { type: string }
breakdown: { type: array, items: { type: object } }
# ── Your own screen. ─────────────────────────────────────────────────────────
views:
- id: records
name: Records
target: dashboard.page # a full page at /dashboard/apps/vendor:example-app/records
component: list:record
- id: customer-records
name: Customer records
target: contact.tabs # a panel under the contact card
component: list:record
knowledge:
- id: terms
title: What the quote includes
content: >
State plainly what a quote does and does not cover. The agent answers from this
text, so ambiguity here becomes ambiguity in front of the customer.
Install it, and the agent quotes from your service inside a live conversation while your operator gets a Records page in the dashboard. Zero lines changed in Vendu.
Note what deliberately stays OUTSIDE the manifest: the calculation itself lives in your service. Anything that must be exact and auditable — money above all — belongs in your code, not in a language model's answer.
3. Views: the three targets
views[].target must be one of the whitelisted slots — the list is
src/modules/platform/core/view-targets.ts, shared by the
manifest validator and the runtime so "declarable" and "renderable" cannot drift apart.
| Target | Where it renders |
|---|---|
contact.tabs | under the contact card |
product.tabs | under the product edit form |
dashboard.page | a standalone page at /dashboard/apps/<vendor:slug>/<viewId>, linked from Settings → Apps |
views[].component takes exactly two forms:
list:<resourceId>— Vendu renders a table of that resource's records (max 20 rows). The resource must be declared by the same app; a view cannot read another app's records even inside one tenant.custom:<https url>— your own UI in an iframe. Rules, enforced at install and again at render:- HTTPS only; private, loopback,
.local/.internaland numeric-encoded hosts are rejected; - the frame is sandboxed without
allow-same-origin, so it runs in an opaque origin and cannot touch the dashboard DOM, cookies or storage. Your own cookies will not be available.
- HTTPS only; private, loopback,
On a contact.tabs or product.tabs slot Vendu appends the entity to your URL:
https://your-app.example/panel?vendu_entity_type=builtin:contact&vendu_entity_id=<id>
Treat these as navigation, never as authorisation. The frame is unauthenticated and anyone can edit a query string, so decide what a viewer may see using your own credential — not because an id arrived in the URL. The workspace id is deliberately not included: URLs reach browser history, referrers and third-party logs, and your app already knows which workspace installed it.
A view that fails either check renders a visible error naming the reason, never a blank panel.
4. Skills: the contract your endpoint must honour
- Auth: bearer or HMAC — see Authenticating your endpoint below. Never declared in the manifest.
- Timeout: 30 s default, 60 s hard ceiling. One retry on 5xx/timeout.
- Response cap: 256 KB; larger responses are truncated.
- Idempotency: every call carries
Idempotency-Key= SHA-256(conversationId + skillId + args). The same key WILL arrive twice — at-least-once delivery is the platform's contract. Make writes idempotent. - Call budget: an app gets 8 tool calls per conversation turn, and 3 calls to any single skill
(
skill-call-budget.ts). On exhaustion the agent is told the budget is gone and moves on. Design for one call per question, not a chat between the model and your API. - Validation: input and output are validated against your JSON Schemas. A wrong field fails before your endpoint is called (input) or before the model sees it (output).
- SSRF: endpoints are resolved and checked; private/loopback targets are refused.
Write skill descriptions as instructions to the agent, not documentation — "Always call this when the customer asks about price" outperforms "Calculates the price".
Authenticating your endpoint
Auth is set per app, not per skill: every HTTP skill an app declares shares one credential. There is no auth field in the manifest — the credential is a secret, and a manifest is a document meant to be stored, diffed and shared.
Three modes:
None. Vendu sends no credential. Only sensible when your endpoint is safe to call by anyone who guesses the URL, which is almost never true for something that quotes prices.
Bearer. Vendu sends:
Authorization: Bearer <your secret>
HMAC. Vendu signs the request body and sends two headers:
X-Vendu-Signature: sha256=<hex>
X-Vendu-Timestamp: <unix milliseconds>
The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>") over the exact bytes sent. Verify it like this:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody, headers, secret) {
const timestamp = headers['x-vendu-timestamp']
const received = String(headers['x-vendu-signature'] ?? '').replace(/^sha256=/, '')
// Reject anything older than five minutes: a captured request must not stay
// replayable forever.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(received, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
Sign the raw body, before any JSON parsing and re-serialising: a re-serialised object differs by whitespace and key order, and the signature will not match.
This is NOT the same format as outbound webhooks. Webhooks use a Stripe-style single header
(X-Vendu-Signature: t=<seconds>,v1=<hex>); skill calls use the two headers above with milliseconds. The
header name is the same and the encoding is not, so a verifier written for one will silently reject the other.
If you handle both, write two verifiers.
The mode and the secret are set by the tenant in Settings → Apps → your app → Доступ до вашого сервісу, on the installed app rather than in the manifest. The secret is stored encrypted and never shown again: it can be replaced at any time, which is how you rotate it.
Known limit: credentials are per app, not per skill, and there is no OAuth flow. An app whose skills need different credentials has to be split into two apps.
The request your endpoint receives
POST <your endpoint>
Content-Type: application/json
Idempotency-Key: <sha256 hex>
Authorization: Bearer … (bearer mode)
X-Vendu-Signature: sha256=… (hmac mode)
X-Vendu-Timestamp: … (hmac mode)
{ …the arguments, exactly as your inputSchema describes them… }
The body is the arguments object itself — there is no envelope, and no tenant or conversation id is included. Your service is told what to compute, not who is asking; identity comes from the credential.
Any 2xx is a success and its body is validated against your outputSchema. A 5xx or a timeout is retried once;
a 4xx is not.
Config and secrets
Anything a tenant must supply — an API key, an account id, a regional setting — is declared as a config field:
config:
- key: pricing_api_key
type: secret # string | number | boolean | enum | secret
required: true
description: Key for your pricing service
- key: default_currency
type: enum
enumValues: ["USD", "EUR", "UAH"]
required: false
The tenant fills these in Settings → Apps → your app. A secret field is stored encrypted and is never
readable back through the UI or the API — it can be replaced, not retrieved. Every other type is stored as
ordinary configuration.
Declaring a config field does not authenticate your endpoint. That is the auth credential above, and the two are set in different places.
5. Resources and the agent
Two independent opt-ins, and read never implies write:
exposeToAi: true→ the agent can callquery_custom_resource(read-only, max 10 records per call).allowAiWrite: true→ the agent can also callsave_custom_resource. Writes route through the Supervisor, the same gate ascreate_order, and carry a deterministic external id so a replayed turn conflicts instead of duplicating.
Leave allowAiWrite off unless a conversation genuinely has to create records.
Linking records to a contact, order or product
A record can be linked to something Vendu already knows about. Declare it on the resource:
resources:
- id: quote
name: Quote
relations:
- target: builtin:contact # builtin:contact | builtin:order | builtin:product
type: belongs_to # belongs_to | has_many
fields:
- { name: total, type: number, required: true }
Two things depend on this, and both look like bugs when the relation is missing:
A contact.tabs or product.tabs view lists only records linked to the record being viewed. Without the
relation nothing is linked, so the tab is empty. There is no field name that does this implicitly — a
contactId string field is just a string, and the tab will not use it.
The agent links what it creates. When a resource declares belongs_to builtin:contact and the agent saves a
record with save_custom_resource, the link to the current conversation is written for you. Without the
declaration the record is saved but belongs to nobody, and no contact tab will ever show it.
A dashboard.page view is not filtered by anything — it is the whole list, deliberately.
Known limit: relations are declared per resource, not per record, and a record's links cannot be edited through the REST API yet. Links are created when the agent writes a record, or by writing the relation row directly.
6. Build, test, install
| Step | Where |
|---|---|
| Author the manifest | Settings → Apps → builder UI, or write the YAML directly |
| Generate a draft from a description | POST /api/platform/copilot/generate |
| Test a skill against your live endpoint | POST /api/platform/skills/test |
| Run inline JS in the sandbox | POST /api/platform/sandbox/execute |
| Read/write records over REST | /api/platform/resources/<resourceFullId> — see below |
| Platform REST API (conversations, orders, products) | /api/v1/* — see the API reference |
| Install into your own workspace | App builder → Publish |
For agent-to-agent integration, Vendu is also an MCP server with OAuth and per-tenant scopes.
Reading and writing records from outside
GET /api/platform/resources/<resourceFullId>?limit=50
POST /api/platform/resources/<resourceFullId>
GET|PATCH|DELETE /api/platform/resources/<resourceFullId>/<id>
Authorization: Bearer vk_live_…
resourceFullId is vendor:slug:resourceId — your app's id and the resource's id, url-encoded. A resource
declared as id: lot inside app acme:auto-import is acme%3Aauto-import%3Alot.
Authenticate with a REST API key (Settings → Developers). A browser session works too, which is how the
dashboard uses it — but a key is what an external service or a custom: view needs, since that view runs in a
sandboxed iframe with no cookies.
A key narrows what the app was granted; it never widens it. A read-only key cannot write to an app's records even if the app itself has write scope, and a key on a workspace where the app has no access reaches nothing.
Lists are cursor-paginated. This is the way past the 20-row cap on a list: view: read the records yourself and
render them in a custom: view.
7. Known limits
Honest list, so you find out here rather than halfway through a build:
- No sidebar injection. A
dashboard.pageview is reachable by URL and linked from Settings → Apps, but does not add a main-navigation item yet. - Only three view targets.
order.tabs,conversation.sidebarandsettings.sectiondo not exist. Ask before working around one — adding a target is cheap; working around it is not. - No resource-schema migrations. Changing a resource's fields does not migrate records already stored. Additive changes are safe; renames and type changes are not.
- Field types are
string,number,boolean,dateandenum— there is no money type, andnumbercarries no precision guarantee. Store amounts in minor units (cents) if exactness matters. - A
list:view shows at most 20 rows, with no paging, sorting or search. Past that, read the records through the REST API above and render them in acustom:view. - One credential per app, no per-skill auth and no OAuth flow.
- Flows subscribe to platform events only. An app cannot yet publish its own event type.
- Custom iframes get no storage (see §3) — by design, revisit only with a same-origin hosting story.
8. The rule, restated
Before asking for a core change, run the test: would three unrelated businesses want this?
- "Call an external calculator mid-conversation" — wanted by anyone who quotes a price. → platform. It exists:
skills. - "Apply this industry's tax formula to this industry's field" — wanted by one vertical. → your app.
Verbs belong to the platform. Nouns belong to your app.