Vendu Docs
ProductDevelopers
/

Product

Getting started

  • What Vendu does
  • How the agent decides what to say

Team

  • Roles and permissions

Orders

  • The order lifecycle

Automation

  • Triggers and actions

Apps

  • What apps are

Conversations

  • When the agent hands over

Developers

API

  • Build on Vendu
  • API error codes
  • Make your first API call
  • MCP server
  • Migrate from offset to cursor pagination
  • Outbound webhooks
  • Rate limits
  • Versioning and deprecation

Apps

  • Build an app

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_engine or src/modules/catalog to 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.

PrimitiveWhat it gives youRuns where
skillsa tool the AI agent can call mid-conversationyour HTTPS endpoint (or sandboxed inline JS)
resourcesyour own record type with typed fields + relationsVendu's DB, isolated per tenant
knowledgedomain text that lands in the agent's retrievalVendu
flows + eventsreact to something happeningVendu
viewsyour own screen inside the dashboardVendu (table) or your URL (iframe)
configper-tenant settings and secretsVendu (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.

TargetWhere it renders
contact.tabsunder the contact card
product.tabsunder the product edit form
dashboard.pagea 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/.internal and 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.

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 call query_custom_resource (read-only, max 10 records per call).
  • allowAiWrite: true → the agent can also call save_custom_resource. Writes route through the Supervisor, the same gate as create_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

StepWhere
Author the manifestSettings → Apps → builder UI, or write the YAML directly
Generate a draft from a descriptionPOST /api/platform/copilot/generate
Test a skill against your live endpointPOST /api/platform/skills/test
Run inline JS in the sandboxPOST /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 workspaceApp 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.page view 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.sidebar and settings.section do 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, date and enum — there is no money type, and number carries 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 a custom: 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.

Last updated 2026-07-29

On this page

  • 1. What an app is
  • 2. The shape of an app
  • 3. Views: the three targets
  • 4. Skills: the contract your endpoint must honour
  • Authenticating your endpoint
  • The request your endpoint receives
  • Config and secrets
  • 5. Resources and the agent
  • Linking records to a contact, order or product
  • 6. Build, test, install
  • Reading and writing records from outside
  • 7. Known limits
  • 8. The rule, restated