informer.yaml Reference
The informer.yaml file is the central configuration file for your Magic App. It declares the data your app depends on, custom roles, agents, events, live channels, and dashboard widgets.
Place it in your project root. It's uploaded to the app's library on deploy and enforced at runtime by the Informer server.
Basic Structure
# informer.yaml
# Typed slots the installer binds at first deploy. Slot names are
# referenced from server-side handler code as `context.<slot>.<method>(...)`
# and arrive pre-typed.
dependencies:
sales:
target: dataset
defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
summary:
target: query
defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
salesforce:
target: integration # no defaultBinding — installer picks
# Raw API allowlist for endpoints that don't fit the typed-slot model.
# Coexists with `dependencies:`; both are honored.
access:
apis:
- POST /api/models/go_everyday/_object
roles:
- id: viewer
name: Viewer
description: Read-only access
Requires Section
What the app needs from the platform itself. Today one key is understood:
requires:
informer: '>=2026.1.3' # a semver range against the Informer build version
A deploy on a server that does not satisfy the range is refused before
anything runs, from the CLI, the builder, a draft commit, or a marketplace
install alike, with a message naming both versions (code: apps_requirement_unmet). Hotfix and RC builds count as their base version,
so 2026.1.3-hot… satisfies >=2026.1.3. Unknown keys and non-semver
ranges are refused so a typo cannot pass silently.
Use it for an app that cannot work at all without a newer platform feature.
An app that should still install on older servers leaves it out and
feature-detects at runtime instead (window.__INFORMER__.platform and the
handler context's platform, whose capabilities are the flags the deploy
gates read): older releases never read this key, so the floor cannot
protect them, but detection works everywhere.
Dependencies Section
The dependencies: block is the preferred way to declare data access. Each entry defines a typed slot that the installer binds (or leaves to the manifest's defaultBinding) at deploy time. The slot becomes a property on the handler's context object — call methods on it instead of building raw API URLs.
| Field | Required | Description |
|---|---|---|
target | yes | One of dataset, query, datasource, integration, app, pack — the resource type this slot binds to |
description | no | Author-facing copy shown in the install/rebind UI |
runAs | no | user (default) or owner. owner bypasses the viewing user's permissions — use sparingly |
options | no | Per-target options (dataset filters, integration headers, etc.) — validated against the driver's schema |
defaultBinding | no | A UUID the slot binds to automatically on first deploy. Look up via the resource's *-list endpoint (e.g. GET /api/datasets-list). Never overwrites a hand-bound slot — re-deploys won't silently rewire installer choices. |
defaultBinding accepts UUIDs only, not configIds (admin:sales-data). UUIDs round-trip cleanly through bundle export/import and survive resource renames. configIds in defaultBinding are rejected at deploy with a clear must be a UUID error.
Calling Slots from Handler Code
Each slot becomes a property on the context object passed to your handlers. Methods depend on the slot's target:
// informer.yaml declared:
// dependencies:
// orders: { target: dataset, defaultBinding: <uuid> }
// summary: { target: query, defaultBinding: <uuid> }
// analytics: { target: datasource, defaultBinding: <uuid> }
// salesforce: { target: integration, defaultBinding: <uuid> }
// kanban: { target: app } # bound by the installer
export async function GET({ context }) {
// dataset → search(esQuery) / fields()
const hits = await context.orders.search({
query: { range: { total: { gte: 100 } } },
size: 50
});
// query → execute(params)
const summary = await context.summary.execute({ month: '2026-05' });
// datasource → query(payload), returns the rows as an array
const rows = await context.analytics.query({
language: 'sql',
payload: 'SELECT type, COUNT(*) AS n FROM events WHERE day = $day GROUP BY type',
params: { day: '2026-05-12' },
inputs: { multiInput: { inputs: [{ name: 'day', component: { mdInputBox: {} } }] } }
});
// integration → request(opts)
const sf = await context.salesforce.request({
method: 'GET',
url: '/services/data/v59.0/sobjects/Account/00112233'
});
// app → request(opts)
const board = await context.kanban.request({ method: 'GET', url: '/stats/summary' });
return { hits, summary, rows, sf, board };
}
If the installer hasn't bound a slot yet (or the bound target was deleted), the proxy throws a boom 422 with a structured errorCode:
try {
return await context.orders.search({ query: { match_all: {} } });
} catch (err) {
// err.output.payload.data.errorCode is 'dependency_unbound' (slot
// never bound) or 'dependency_broken' (bound target deleted).
if (err.output?.payload?.data?.errorCode === 'dependency_unbound') {
return { status: 503, body: { error: 'This app needs setup — bind the orders dataset.' } };
}
throw err;
}
Target Types
dataset
Grants search(esQuery) and fields() on the bound dataset.
dependencies:
sales:
target: dataset
defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
Server-side, this maps to:
POST /api/datasets/{uuid}/_search— Elasticsearch queriesGET /api/datasets/{uuid}/fields— Field metadata
query
Grants execute(params) on the bound saved query. Pass the query's inputs by name, and execute() returns the rows as an array of objects keyed by the query's field names (column names for a native SQL query, field ids for one built in the query designer). Columns with saved formatting are run through it, so their values may not come back raw. A binding's options.parameters supplies default input values; inputs you pass override them.
dependencies:
daily_summary:
target: query
defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
const rows = await context.daily_summary.execute({ region: 'North' });
Server-side: POST /api/queries/{uuid}/_execute
datasource
Grants query(payload) on the bound datasource. The payload names the query language and carries the query, and query() returns the rows as an array of objects keyed by field name (for SQL, the column names).
dependencies:
postgres_main:
target: datasource
defaultBinding: 3e4f5a6b-7c8d-9e0f-1234-567890abcdef
const rows = await context.postgres_main.query({
language: 'sql',
payload: 'SELECT id, total FROM orders WHERE status = $status ORDER BY id',
params: { status: 'open' },
inputs: { multiInput: { inputs: [{ name: 'status', component: { mdInputBox: {} } }] } },
limit: 500
});
- SQL binds each
$namereference to its value inparamsand escapes the value. Declaring each name underinputsis optional but preferred, because the input fixes the type:component: { mdInputBox: {} }for text andcomponent: { mdInputBox: { type: 'number' } }for a number. Onceinputsare declared, only declared names bind. - Without
inputs, each$namethat has a param binds with a type guessed from the value: a number, a boolean, or otherwise text. Declareinputsfor a date or timestamp, a user field, a default or a required check, which a guess cannot express. - A
:nameand$1are not parameters and stay in the SQL as written. - Write
$nameeven inside a template literal.${name}is JavaScript interpolation and pastes the raw value into the SQL. - Never put
$nameinside a quoted string or a comment: it is replaced there too, and the value can break out. Put wildcards in the value instead,title LIKE $qwithparams: { q: `%${term}%` }. - A
null,''or[]value bindsNULL, so compare it withIS NULL. An array binds one value per item, so match a list withIN ($ids). - On Postgres, MSSQL, MySQL, DB2, App and workspace Datasources, write a literal
?(such as the Postgres jsonb?operator) as\?, or\\?inside a JavaScript string. It runs as a literal?whether or not anything is bound, while a bare?counts as a placeholder once the payload binds a$nameor${user.*}reference. On Oracle and generic JDBC Datasources,\?is not an escape. limitcaps the rows; leave it out to return all of them.
Server-side: POST /api/datasources/{uuid}/_query
integration
Grants request(opts) on the bound integration (Salesforce, REST APIs, etc.).
dependencies:
salesforce:
target: integration
defaultBinding: 5a6b7c8d-9e0f-1234-5678-9abcdef01234
Server-side: POST /api/integrations/{uuid}/request
app
Binds another installed App so your server/ routes can invoke the target App's own server/ routes:
request(opts): invoke aserver/route on the target App
dependencies:
kanban:
target: app
description: The board this dashboard drives
export async function GET({ context }) {
// Call one of the target App's server routes
const stats = await context.kanban.request({ method: 'GET', url: '/stats/summary' });
return { stats };
}
Rules specific to this target type:
- Binding an App requires read access to it, the same boundary as every other target type. Attempting to bind an App you cannot see fails with a 403 (existence is not disclosed).
request()is limited to one hop. App A may call App B, but a handler B runs on A's behalf cannot then call into App C (or back into A). A second hop fails with a 508 anderrorCode: 'app_dependency_depth_exceeded'. This keeps app-to-app calls from chaining or looping.request()goes through the target App's own gate. The call is dispatched exactly like a direct request to the target: the identity fromrunAsmust be able to see the target App, the target's routeconfig.rolesapply, and compute is metered against the target.- An App cannot bind to itself. Self-binding is rejected at bind time with a 400, including a manifest
defaultBindingthat points a slot at its own App. apptargets require the full App tier. A Magic Report declaringtarget: appfails to deploy; that tier supports only dataset / query / datasource bindings.
To run SQL over another App's data, do not reach for app. Bind that App's workspace Datasource through a target: datasource slot instead: it is a first-class Datasource whose ownership and access rules already govern who may query it, and it gives you the standard query(payload) surface.
Unbound and broken slots throw the same structured 422 (dependency_unbound / dependency_broken) as every other target type.
pack
Depends on a marketplace pack rather than an arbitrary installed App. Where a target: app slot lets the installer pick any App they can read, a pack slot is pinned by the author: you name the pack and the version range you support, and the runtime finds the locally-installed copy of that pack by itself.
request(opts): invoke aserver/route on the pack's installed app — same surface, envelope, and one-hop rules astarget: app
dependencies:
kanban:
target: pack
pack: informer-kanban # the pack's marketplace slug
requires: ">=1.2 <2" # semver range your code supports
description: The Kanban board this dashboard drives
Both pin fields are required and author-owned:
| Field | Required | Description |
|---|---|---|
pack | yes | The pack's marketplace slug |
requires | yes | A semver range the installed pack version must satisfy. Prerelease-inclusive, so a beta-channel install like 1.4.0-beta.1 satisfies >=1.2 <2. |
How a pack slot behaves:
- A single in-range install is implicitly consented. The author pinned the pack and the admin installed both apps — with exactly one in-range install there is nothing left to decide, so the slot is usable immediately (the consent is recorded and audit-logged at install/deploy). The only manual step left is the channel pick: when stable AND beta are both installed and in range, Settings → Dependencies asks which one this app should call (switchable any time).
- Resolution is late and self-healing. Every call resolves the pin against the current marketplace installs (stable install preferred over beta). Uninstalling and reinstalling the pack — which re-mints its local app — does not break the slot.
- The version gate fails hard. If the installed pack version leaves
requires(e.g. the installer updates to a new major), calls throw a 422 witherrorCode: 'pack_dependency_out_of_range'carryinginstalledVersionandrequires— a wrong-major API answering "successfully" is worse than a clean failure. - The pin is not re-pointable. Neither the installer nor a rebind can change
pack/requires; only redeploying the app with an updated manifest does. Re-deploys refresh the pin like other author-owned metadata. defaultBindingis rejected. The pin is the binding identity; a UUID would defeat the marketplace resolution.- Structured runtime errors for every state: not installed →
dependency_unbound; both channels in range with no pick →dependency_unconsented; consented-but-gone →dependency_broken; version out of range →pack_dependency_out_of_range. Feature-detect onerr.data.errorCodeto teach the right next step.
All the target: app runtime rules apply to the delegated call: one-hop depth, the target's own route roles and metering, runAs identities.
Local development: there is no marketplace install to resolve against in dev, so point the slot at your locally-installed copy of the pack via the vite plugin's devBindings:
// vite.config.js
informer({
devBindings: {
kanban: { app: 'admin:kanban' } // your local copy of the pack's app
}
})
Row-Level Security (dataset options)
Restrict data based on the viewing user's profile. Filters live under each slot's options:
dependencies:
orders:
target: dataset
defaultBinding: 1f2e3d4c-5b6a-7980-1234-56789abcdef0
options:
filter:
region: $user.custom.region # Users only see their region
sales_rep: $user.username # Users only see their own records
status: [open, pending] # Either value matches
Filters are injected server-side into every search request. The app code doesn't need to handle filtering — it's automatic and tamper-proof.
Each key is a Dataset field, and a row must match every key. A key's value decides which rows match:
- One value matches rows where the field equals it.
- A User Field holding several values, such as a user assigned to both Nursing and Pharmacy, matches rows equal to any of them. Text around the variable joins each value, so
org-$user.custom.schoolmatchesorg-Nursingororg-Pharmacy. - A list matches rows equal to any entry. A variable entry adds every value it holds, so
school: [Shared, $user.custom.school]shows shared rows plus the user's own. An entry that resolves to nothing adds nothing. - A User Field the user has no value for uses the field's Default Value.
- A key left with nothing to match on matches no rows. That happens when its variable resolves to nothing (an empty User Field with no Default Value, or a variable that doesn't exist), including one embedded in text like
org-$user.custom.dept, when its value is empty (''or[]), and when its value is not text, a number, ortrue/false. The viewer sees nothing from the slot, never every row its runAs identity can read. - A
filter:that is not a map of fields, including one left empty with nothing under it, fails the deploy in a slot. In a legacyaccess.datasetsentry it matches no rows at all.
Credential Injection (integration options)
Pass user-specific credentials to external APIs via the slot's options:
dependencies:
partner_api:
target: integration
defaultBinding: 5a6b7c8d-9e0f-1234-5678-9abcdef01234
options:
headers:
Authorization: Bearer $user.custom.partnerToken
X-Client-ID: $tenant.id
params:
user_id: $user.custom.externalId
Headers and params are expanded server-side — sensitive values are never exposed to client JavaScript.
Path Restrictions (integration options)
Limit which endpoints the app can call on an integration:
dependencies:
salesforce:
target: integration
defaultBinding: 5a6b7c8d-9e0f-1234-5678-9abcdef01234
options:
paths:
- /data/*/query
- /data/*/sobjects/**
- Paths support basic & advanced glob syntax such as wildcards
*and?, globstars**(for nested paths - Use with Caution!), character escaping with\backslash, and more. See micromatch/picomatch for detailed documentation.
runAs
By default, every dep call runs with the viewing user's credentials. To run as the app owner (bypassing the viewer's permissions), set runAs: owner:
dependencies:
curated_view:
target: dataset
defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
runAs: owner # Trusted — bypasses viewer permissions
Use owner sparingly — it's how you deliberately share data the viewer wouldn't otherwise see.
Integrations Section
A dependencies: slot with target: integration binds to an Integration that already exists in Informer, which someone set up out of band. The integrations: section flips that around: your app declares the Integration it needs, and deploy creates it for you. No admin has to pre-build a connection before the app can run, and no Integration UUID lives in your manifest.
Each entry materializes a real Informer Integration owned by the app and, at the same time, an already-bound dependency slot of the same name. So this:
integrations:
wake:
name: Wake ABC
apiBaseUri: https://wakeabc.com/
gives your handler code context.wake.request(...) with nothing else to wire up. The slot is managed by deploy: it never shows as "needs binding" in the install panel, and it can't be rebound by hand. Change the manifest and redeploy to change it.
Owned Integrations still go through the same secure proxy as bound ones. The single auditable gate, the host-locked base URL, and the "no arbitrary egress from app code" guarantees all hold. An owned Integration also appears in the tenant's Integrations admin list, badged with the App it belongs to.
Fields
| Field | Required | Description |
|---|---|---|
apiBaseUri | yes | Base URL every request is resolved against. http/https only. For OAuth, may instead be a $connection.<field> reference (see below). |
name | no | Display name. Defaults to the entry key. Used for the auto-matched service icon. |
description | no | Author-facing copy shown in the install panel. |
authType | no | none (default), apiKey, basic, bearer, or oauth2. |
apiKey | for apiKey/bearer | A $env.KEY reference to the key. Never a literal. |
apiKeyName | no | Header or query-param name for the key (apiKey auth). |
apiKeyLocation | no | header (default) or query. |
clientId | for basic/oauth2 | Client id. May be a $env.KEY reference. |
clientSecret | for basic | A $env.KEY reference to the secret. Never a literal. |
authUri, tokenUri, scope, revocationUri, basicTokenAuth | for oauth2 | OAuth provider endpoints and scopes. |
mode | no | per-user (default) or shared. OAuth only. |
icon | no | Service icon. See Icons. |
optional | no | true if the app works without this Integration. See Optional slots. |
Secrets Never Live in the Manifest
The secret-bearing fields (apiKey, clientSecret) accept only a $env.KEY reference, so a plaintext secret can never land in a checked-in file. The value lives encrypted per-tenant in the app's Environment (the Environment tab in the app admin panel), and the key it references must be declared in the manifest's env: section or the deploy fails.
integrations:
stripe:
authType: apiKey
apiBaseUri: https://api.stripe.com/v1
apiKey: $env.STRIPE_KEY # resolved per-tenant at request time
env:
STRIPE_KEY:
description: Stripe secret key
The installer fills STRIPE_KEY once per tenant. Nothing about the secret is in git, and it is never returned by any API.
Auth Types
An unauthenticated integration (authType: none, the default) still earns its keep: a single auditable gate and a host-locked base URL, so app code can only ever reach the one host you declared.
integrations:
wake:
name: Wake ABC
apiBaseUri: https://wakeabc.com/
API key in a header (the default) or a query parameter:
integrations:
stripe:
authType: apiKey
apiBaseUri: https://api.stripe.com/v1
apiKey: $env.STRIPE_KEY
apiKeyName: Authorization # optional; defaults to X-API-Key
apiKeyLocation: header # optional; header (default) or query
Bearer token (sent as Authorization: Bearer <key>) and HTTP basic (clientId + clientSecret) work the same way, keyed off $env references.
OAuth 2.0 is the richest case. See OAuth Integrations.
OAuth Integrations
An authType: oauth2 entry declares the provider's endpoints; the user grants consent from inside the app, through the same Connections surface every other connected service uses.
integrations:
sf:
name: Salesforce
authType: oauth2
apiBaseUri: $connection.instance_url # resolved from the token response
clientId: $env.SF_CONSUMER_KEY
clientSecret: $env.SF_CONSUMER_SECRET
authUri: https://login.salesforce.com/services/oauth2/authorize
tokenUri: https://login.salesforce.com/services/oauth2/token
scope: api refresh_token
Two things are worth calling out:
$connection.<field> base URLs. Some providers hand back the account's own host in the token response. Salesforce returns instance_url (the org's My Domain URL). Setting apiBaseUri: $connection.instance_url means each connection self-configures to whichever org its user authenticated against, so an installer never has to type (or mistype) their org URL. The field is read from the OAuth token response at consent time.
Per-user vs shared. mode: per-user (the default) means every viewer connects their own account and sees exactly what their provider permissions allow, with nothing shared between users. mode: shared means one connection, established once by the app owner, that every viewer rides.
For a single institution, register your own provider app (your own Salesforce Connected App, Entra app registration, and so on) and hold its secret as an app secret via $env, exactly as above. The callback URL to register with the provider is your License Manager's /integration/callback endpoint. Marketplace-distributed apps, where the publisher's credentials are held centrally and referenced by the app, are a separate registration path.
Icons
By default deploy matches the Integration's name against a registry of well-known services and uses that service's mark. name: Salesforce gets the Salesforce icon for free.
To ship your own, put an icon in the app library and point at it by path:
integrations:
wake:
name: Wake ABC
apiBaseUri: https://wakeabc.com/
icon: icons/wake.svg # a file in your app's library
An absolute path or URL (/images/icons/integrations/slack.svg) is used as-is. A library path that doesn't resolve fails the deploy. The icon shows anywhere the Integration appears, including the in-app Connections list.
Optional Slots
Mark an Integration (or any dependencies: slot) optional: true when the app works without it. Optional slots stay out of the install panel's "needs binding" count, and an unconnected optional OAuth Integration is presented as opt-in rather than a warning. Your handler code still gets the same typed errors when it calls an unbound or unconnected slot, so you can feature-flag the panels that depend on it.
integrations:
crm:
authType: oauth2
optional: true # the app runs without a CRM connection
# ...provider fields...
Environment Variables Section
The env: section declares the keys your app reads at runtime. Values are never stored in informer.yaml. They live encrypted, per-tenant, and are filled in on the Environment tab of the app admin panel.
env:
STRIPE_KEY:
description: Stripe secret key for live API calls
API_BASE_URL:
description: Base URL of the upstream API
Each key can also be written as a bare KEY: (no description) or KEY: some description (the string is the description). Deploy seeds an unset placeholder for every declared key, so the installer sees exactly which secrets a tenant needs to supply.
Handlers read them from the env bag:
export async function POST({ env, fetch }) {
const res = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { Authorization: `Bearer ${env.STRIPE_KEY}` }
});
}
Only keys with a value present appear in env, so env.STRIPE_KEY is undefined until the installer fills it. Values are encrypted at rest and never returned by any API. On a cross-tenant bundle export, keys and descriptions travel but values do not; the receiving installer fills them in.
Access Section (Raw API Allowlist)
For API paths that don't fit the typed-slot model — custom endpoints, AI model routes, internal APIs — use the access.apis: block:
access:
apis:
- POST /api/custom/endpoint
- GET /api/special/resource
- POST /api/models/go_everyday/_chat
- POST /api/models/go_everyday/_completion
- POST /api/models/go_everyday/_object
- /api/read-only/resource # METHOD defaults to GET if omitted
- Each entry is a string in the format
METHOD /api/path - If the HTTP method is omitted, it defaults to
GET. - Paths support basic & advanced glob syntax such as wildcards
*and?, globstars**(for nested paths - Use with Caution!), character escaping with\backslash, and more. See micromatch/picomatch for detailed documentation.
You can still declare access.datasets, access.queries, access.integrations, access.datasources, and access.libraries instead of using dependencies:. The runtime extracts those into the same app_entity whitelist, but they don't appear in the install/rebind UI — every change requires editing the YAML and redeploying. For new apps, prefer dependencies: slots.
Modernizing a Legacy access: App
The server provides a one-shot route to convert a legacy access:-based manifest into dependencies: slots:
POST /api/apps/{id}/_modernize-manifest
The route:
- Takes a snapshot first (
app_snapshotrow labeledmodernize) — reversible via the returnedsnapshotId. - For each entry under
access.datasets/access.queries/access.datasources/access.integrations, generates adependencies:slot with a UUIDdefaultBindingresolved at rewrite time. - Keeps
access.apisintact — raw paths don't have a slot model. - Deploys the rewritten manifest.
A failure after the snapshot is taken returns 400 with the snapshotId so you can restore. You'll also need to update any handler code that referenced these resources via raw fetch('/api/datasets/<id>/_search', ...) to use context.<slot>.search(...) instead.
Roles Section
Define custom roles that publishers assign when sharing the app:
roles:
- id: viewer
name: Viewer
description: Can view reports but not take actions
- id: approver
name: Approver
description: Can approve or reject requests
- id: manager
name: Manager
description: Full management access
| Field | Required | Description |
|---|---|---|
id | Yes | String identifier used in code |
name | Yes | Display name shown in share dialogs |
description | No | Help text shown in share dialogs |
Reading Roles
Client-side:
const roles = window.__INFORMER__?.roles || [];
if (roles.includes('approver')) {
showApprovalPanel();
}
Server-side (route handlers):
// Automatic enforcement via config
export const config = {
roles: ['admin', 'manager'] // 403 if user lacks these roles
};
// Manual checking
export async function GET({ request }) {
if (request.roles.includes('manager')) {
// show admin data
}
}
How Roles Are Assigned
- Internal shares — Publisher selects roles in the share dialog
- External links — Publisher selects roles when creating the link; roles are baked into the token
- Publisher+ on owning team — Automatically receives all defined roles
- No roles defined —
window.__INFORMER__.rolesis[]
Variable Reference
Variables are expanded server-side, keeping sensitive values secure.
| Variable | Description |
|---|---|
$user.username | Login name |
$user.email | Email address |
$user.displayName | Full name |
$user.custom.xxx | Custom user field value, or the field's Default Value when the user has none |
$tenant.id | Tenant identifier |
$tenant.name | Tenant display name |
$app.id | App UUID |
$app.name | App display name |
$app.naturalId | App natural id (owner:slug) |
$app.owner | App owner (principal id) |
Only the variables listed above resolve — any other path (e.g. $tenant.tokenSecretHash) is not expandable: it resolves to an empty string in a header or parameter and matches no rows in a filter, so credentials and tenant secrets can never be leaked through any of them.
A variable that resolves to nothing (e.g., $user.custom.region when the user has no region value and the field has no Default Value) becomes an empty string in a header or parameter. A dataset filter instead matches no rows, so that user sees nothing from the slot. Give your users a value, or the field a Default Value, for every custom field a filter references.
Agents Section
Define AI agents that execute autonomously in response to events, cron schedules, or manual triggers. See the Agents API docs for full details.
agents:
order-processor:
description: Processes new orders and sends confirmations
instructions: |
You are an order processing agent. When triggered by an order_created event,
verify the order details, update inventory, and send a confirmation email.
tools:
- send-email
- update-inventory
on: order_created
model: go_everyday
daily-report:
description: Generates a daily summary email
instructions: Summarize today's orders and email the report to the team.
tools:
- send-email
cron: "0 17 * * 1-5"
crm-agent:
description: Customer support with CRM tools
instructions: Help customers using CRM data and web search.
toolkits:
- admin:crm-toolkit
assistants:
- admin:support-persona
on: support_request
webSearch: true
Agent Fields
| Field | Type | Description |
|---|---|---|
description | string | Human-readable description |
instructions | string | System prompt for the AI model |
tools | string[] | App tool names (from tools/ directory) |
toolkits | string[] | Toolkit natural IDs to attach |
assistants | string[] | Assistant natural IDs — instructions merge into the agent's system prompt |
on | string | string[] | Event name(s) that trigger this agent |
cron | string | Cron schedule (5-field: min hour dom mon dow) |
webSearch | boolean | Enable web search tool |
model | string | AI model slug (default: go_everyday) |
Toolkits
Agents can reference external toolkits that provide additional tools. Toolkit references are resolved at deploy — if a referenced toolkit doesn't exist, deploy fails with a clear error.
agents:
my-agent:
instructions: Use CRM tools to look up customer data.
toolkits:
- admin:crm-toolkit # Resolved by naturalId at deploy
on: customer_inquiry
At execution time, the agent receives all tools from its referenced toolkits alongside its app tools. Toolkit system instructions are appended to the agent's prompt.
Assistants
Agents can reference Informer assistants to inherit their instructions. Each assistant's system prompt is merged into the agent's own instructions at execution time.
agents:
support-bot:
instructions: Handle customer inquiries.
assistants:
- admin:support-persona # Assistant instructions merged in
on: support_request
Assistant references are validated at deploy time — if a referenced assistant doesn't exist, deploy fails with a clear error. At runtime, assistant instructions are prepended to the system prompt before toolkit instructions.
Cron Schedules
Standard 5-field cron format: minute hour day-of-month month day-of-week
agents:
nightly-sync:
instructions: Sync data from external system.
cron: "0 3 * * *" # 3:00 AM daily
weekly-report:
instructions: Generate weekly report.
cron: "0 9 * * 1" # 9:00 AM every Monday
frequent-check:
instructions: Check for new items.
cron: "*/15 * * * *" # Every 15 minutes
Events Section
Declare event types that agents can listen for. Events are emitted from server route handlers via the emit() callback.
events:
order_created:
description: Fired when a new order is submitted
order_shipped:
description: Fired when an order ships
support_request:
description: Fired when a customer opens a support ticket
agents:
order-processor:
on: order_created
instructions: Process the new order.
support-bot:
on: support_request
instructions: Respond to the support ticket.
Event types are stored in app.defn.events on deploy. To emit an event from a server route:
export async function POST({ query, emit, request }) {
const [order] = await query(
'INSERT INTO orders (customer, total) VALUES ($1, $2) RETURNING *',
[request.body.customer, request.body.total]
);
await emit('order_created', { orderId: order.id });
return { status: 201, body: order };
}
Channels Section
Declare live channels that relay events to open pages of the App over a WebSocket. An emit() of an event listed under a channel's on is also broadcast to that channel with the same event name and payload, so dashboards update without polling. See the Channels guide for handlers under channels/, broadcast(), and the page API.
channels:
orders:
description: Live order activity for the Order Desk dashboard
on: [order_created, order_shipped]
payments:
on: payment_received
Channel Fields
| Field | Type | Description |
|---|---|---|
description | string | Optional author-facing note, at most 500 characters |
on | string or string[] | Optional. Event names to relay into this channel; persisted as an array |
Channel names are path-like segments of letters, digits, _, ., - joined by / (orders, orders/east), at most 128 characters; event names use the same characters without /, at most 64. Invalid names fail the deploy with a 400. The block is stored in app.defn.channels; removing it and redeploying removes the relay. Channels require the server to serve Apps in origin mode; on a path-mode server the deploy succeeds with a channels_require_origin_mode warning (see Channels need origin mode).
Widgets Section
Declare dashboard widgets that render inside Informer's widget gallery. Widgets are static HTML files served in iframes at fixed grid sizes.
widgets:
cash-balance:
entry: widgets/cash-balance.html
label: Cash Balance
size: { w: 2, h: 1 }
refresh: 300
sales-trend:
entry: widgets/sales-trend.html
label: Sales Trend
size: { w: 2, h: 2 }
refresh: 300
Widget Fields
| Field | Type | Description |
|---|---|---|
entry | string | Path to HTML file relative to public/ |
label | string | Display name in the widget gallery |
size | object | Grid dimensions: w (width) and h (height) in grid units |
refresh | number | Auto-refresh interval in seconds |
Widget HTML files go in public/widgets/ and are self-contained (inline CSS/JS, no external dependencies). They use the same dependencies: and access: declarations as the main app.
Areas Section
Declare the functional areas of your app that users can pick from when they file feedback or a support request from the marketplace (the Area field on a ticket). Publishing reads the list out of the manifest in the published archive, so the choices on your listing come from the code that shipped rather than from anything you set up on the listing itself.
areas:
- Editor
- Exports
- Scheduling
Rules:
- A plain list of names, at most 50, each at most 255 characters. Names must be unique (case-insensitive); leading and trailing whitespace is trimmed.
- Declared order is the order shown in the picker.
- Quote a name that YAML would read as something other than text.
- 2026parses as a number and- 2024-01-01as a date, and only text can be an area name. - Tickets keep the area they were filed under even if it is later renamed or removed.
Omitting the key (or leaving the list empty) means different things to the two surfaces, because a listing has a second way to get areas:
- The deployed app takes its areas only from the manifest, so an omitted or empty key leaves it offering none.
- The marketplace listing keeps whatever it already has when the key is omitted, since a listing's areas can also be set directly through the marketplace API. Publish an explicit empty list to clear them.
A name that cannot be used does not block a deploy or a publish. Blank entries, entries that are not text, over-length names and repeats are skipped, the rest of the list is kept, and the deploy reports each skipped entry under partialFailures, naming its position in the list and why it could not be used. Declaring more than 50 areas keeps the first 50.
Two cases are treated as "unreadable" rather than "declares none", and both leave the existing areas untouched on the app and on the listing alike: areas: not being a list at all, and a list in which every entry was skipped. Clearing areas over a typo would leave your listing offering areas the installed app does not, so clearing takes an explicit empty list.
Complete Example
# informer.yaml
dependencies:
sales:
target: dataset
defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
orders:
target: dataset
defaultBinding: 1f2e3d4c-5b6a-7980-1234-56789abcdef0
options:
filter:
region: $user.custom.region
monthly_summary:
target: query
defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
salesforce:
target: integration
defaultBinding: 5a6b7c8d-9e0f-1234-5678-9abcdef01234
sendgrid:
target: integration # installer picks at bind time
access:
apis:
- POST /api/models/go_everyday/_object
roles:
- id: viewer
name: Viewer
description: Read-only access
- id: manager
name: Manager
description: Full management access
events:
order_created:
description: New order submitted
order_shipped:
description: Order has shipped
channels:
orders:
description: Live order activity
on: [order_created, order_shipped]
agents:
order-processor:
description: Processes new orders
instructions: Verify order details and update inventory.
tools:
- update-inventory
- send-email
on: order_created
daily-digest:
description: Daily order summary
instructions: Summarize today's orders and email to managers.
tools:
- send-email
cron: "0 17 * * 1-5"
widgets:
order-count:
entry: widgets/order-count.html
label: Today's Orders
size: { w: 2, h: 1 }
refresh: 60
areas:
- Orders
- Inventory
- Daily Digest