Scopes, permissions & security

Scopes, permissions & connections

This page explains how a Telenow app asks for permission, how the org grants it, and the security guarantees the platform enforces around your app. It also documents the two ways your app uses a credential without ever putting it in your bundlestored connections and per-install secret headers — and the exact error messages you will hit when something is misconfigured.

Read it before you ship — it determines what your app is allowed to do and why your code stays safe even when it runs inside someone else's dashboard.

If you are new, two ideas carry the whole page:

  1. You declare scopes in your manifest. The org consents to exactly those scopes when they install your app. Nothing you didn't declare is reachable.
  2. The browser never holds a token. Every privileged call your UI makes is relayed to the dashboard and re-checked server-side under the signed-in user. RBAC helpers like can() only hide buttons — they never grant access. Secrets are injected by the server, after the host has already been allowlisted.

A scope is a single permission string in your manifest's scopes[] array. When an org admin installs your app, they see the full list and approve it. After that, the granted set is frozen.

// telenow.app.json — Doctor CRM declares exactly what it uses
"scopes": [
  "objects:patient",
  "objects:appointment",
  "objects:visit",
  "agents:read",
  "calls:initiate",
  "calls:read",
  "whatsapp",
  "whatsapp:templates",
  "whatsapp:campaign",
  "softphone:dial",
  "user:profile",
  "campaigns:write",
  "campaigns:read"
]

Three rules to keep in mind:

  • Frozen at install. The scopes the org consented to are the scopes you have. You cannot widen them at runtime.
  • Adding scopes forces re-consent. If a later version of your app adds a scope, the org must approve the new set before that version goes live for them. Plan ahead — declaring a scope you'll need soon (but don't yet use) is cheaper than a forced re-consent later, but see Least privilege below for the tradeoff.
  • There is no server-side scope whitelist. This is important and surprises people — see the next section.

Scopes are free-form strings — there is no whitelist

validate() (the check npx telenow validate and the server run at upload) does not validate scopes[] at all. There is no KNOWN_SCOPES list on the server, so:

  • An unrecognised or typo'd scope is never rejected or warned at upload. objcts:patient (note the typo) uploads fine.
  • A typo'd scope silently grants nothing. It is treated as a meaningless string nobody ever checks for.
  • The feature that needed the real scope then fails at runtime with a 403 — not at build time. You won't find out until a call actually tries to use the capability.

Only settings keys are validated against the manifest (an unknown settings key is rejected). Scopes are not — there is no server enforcement of a scope whitelist. The CLI (telenow validate) carries a local typo-guard; as of the current CLI its known-scope list is complete (every scope in the catalogue below, plus the objects: / http: / connection: prefixes). Older CLI versions warned "unrecognised scope" on perfectly valid scopes (agents:write, files:*, campaigns:*, data:*, calls:read:org) — those were false positives; update the CLI rather than renaming a scope to silence one.

Scopes are enforced only at the point of consumption, by string match, at runtime:

Scope stringEnforced where
http:<host>the HTTP proxy, when you call that host
connection:<provider>the proxy, when you inject a stored connection
session:tokenminting a session JWT
files:read / files:writethe Files API
calls:readcall history, mid-call live topics, live-stream ticket — for agents your app is bound to
calls:read:orgthe same call surfaces, org-wide (every agent, no binding needed)
billing:readthe billing API, includeCost on the calls API, the charge.settled event
kb:read / kb:writethe runtime KB API (reads / mutations)
agents:read, campaigns:write, …the matching bridge op / REST route

Takeaway: spell your scopes exactly. A silent typo is the single most common "why doesn't my feature work?" bug, and the only signal is a runtime 403.

Two planes enforce scopes, and they don't use identical names

This trips up almost everyone, so read it before the catalogue.

PlaneWho checksWhere
UI bridgethe dashboard, in the browserevery window.telenow.* call your page makes
RESTthe Rust backendapp-key and OAuth calls from your server

They are enforced independently and a few names differ. The clearest case is WhatsApp: the bridge gates sending on whatsapp:send, while the REST API gates it on whatsapp. Declare only one and the other plane refuses. An app that sends from both its UI and its backend needs both.

Scopes the UI bridge enforces today:

agents:read · agents:write · agents:config:read · agents:config:write (and its :<group> forms) · ai:llm · calls:initiate · calls:transcribe:live · files:read · files:write · kb:read · links:read · links:write · members:read · session:token · softphone:dial · whatsapp:send

A bridge-only scope is not a fake scope — it is real, enforced, and the call fails without it. It just isn't a string the Rust backend has ever heard of, so grepping the backend for it finds nothing.

Workflows are the third case: they check nothing. A workflow step runs with the install's full authority regardless of what you declared. Don't treat a missing scope as a safety net there.

Scope catalogue

What does each of these return? Every endpoint, bridge op, tool and event on this page has a worked response example in the Response reference, derived from the code that builds it and indexed by scope.

These are every scope the platform understands. Most map one-to-one to a capability in the window.telenow bridge or the app-key REST API.

ScopeGrants
user:profilethe signed-in user's name + email in the bridge and session token
session:tokenmint a signed identity JWT for your backend (telenow.session.token() / POST /:appId/session-token)
agents:readlist org agents; create agents/teams from your manifest templates; run evals
agents:writecreate or delete agents from your backend (POST/DELETE /api/app-agents), and publish an agent so a link can reach it (agents.setPublic, owner/admin only). Merely reading the link back (agents.publicLink) needs only agents:read.
agents:config:readread the FULL configuration of an agent your app is bound to — system prompt, model, voice, STT, behaviour, telephony and the flow graph. Secret-free: BYOK keys stripped, tools reduced to name/description. The read scope an agent-improver app starts from
agents:config:write:<group>change one setting group on a bound agent. Groups: prompt · model · voice · stt · behavior · flow · telephony · analysis. The group is decided by the FIELD, so editing a flow node's prompt needs …:write:prompt, not …:write:flow. See Agents
agents:config:writesuperset of every group above. Shown prominently at consent — declare the specific groups instead unless your app genuinely rebuilds an agent end to end
calls:readread call history; receive mid-call live event topics; open the live-call stream — scoped to agents your app is bound to
calls:read:orgeverything calls:read grants, org-wide: every agent's calls (including agents created later), no per-agent binding — the analytics-app scope. Shown prominently at consent. One exception: declarative postCall capture stays binding-only
calls:initiateplace outbound voice calls
calls:transcribe:livetap the live STT stream of a call in progress. Additive to calls:read
whatsappSend messages, list channels, read threads & message history, upload/download media, mark messages read (also gates the whatsapp.message.* webhook events). REST plane — the UI bridge uses whatsapp:send
whatsapp:sendUI-bridge plane: list channels and send from your app's pages
whatsapp:templatesCreate & edit message templates and upload template header media
whatsapp:webalso list and send on WhatsApp Web channels (a paired phone session). Without it, whatsapp.channels() returns Cloud API channels only — see the note below
whatsapp:campaignCreate and control WhatsApp broadcasts (distinct from campaigns:*, which gate voice campaigns)
softphone:dialopen the dashboard dialer prefilled
files:read / files:writeper-app blob storage read / write. files:read also covers text extraction from a stored .pdf / .docx / .txt / .md (telenow.files.extractText())
ai:llmthe App AI Gateway's LLM: telenow.ai.llm(), ai.llmStream(), POST /api/app-ai/llm. Billed to the installing org's wallet
ai:ttsthe gateway's text-to-speech, POST /api/app-ai/tts. App-key REST only — there is no ai.tts() on the bridge. Billed to the org
ai:sttreserved — grants nothing today. It validates and appears at consent, but no /api/app-ai/stt route exists. Use calls:transcribe:live for live call audio
links:write / links:readmint / list + revoke tokenized public links (telenow.links.*) — see below
members:readthe installing org's member roster — user id, name, email, role (telenow.members.list())
campaigns:read / campaigns:writeapp-key Campaigns API (list/status / create/pause/cancel)
billing:readwallet balance + settled per-call charges (your org's prices — the platform's provider cost is never exposed), includeCost on the calls API, and the charge.settled event
kb:read / kb:writeread / manage the app's own knowledge bases at runtime (/api/app-kb): create KBs, push/replace documents, attach to bound agents. Never the org's other KBs
objects:<type>data access to that object type, e.g. objects:patient
http:<host>let the HTTP proxy call that host; supports a leading wildcard, e.g. http:*.googleapis.com
connection:<provider>inject a stored credential for that provider server-side, e.g. connection:salesforce
data:read / data:writeOAuth-token-scoped Data API CRUD (static app keys are not CRUD-scope-gated)

A few notes that trip people up:

  • objects:<type> is per object type. Doctor CRM declares objects:patient, objects:appointment, and objects:visit separately. There is no objects:*.
  • Mid-call live topics need calls:read. The coarse call.started / call.ended / call.analyzed topics don't, but the live ones — call.turn, call.barge_in, call.silence, call.dtmf, call.node_entered — carry transcript content, so they additionally require calls:read (or calls:read:org). See Automation and the live stream in Dashboard UI.
  • calls:read is binding-scoped; calls:read:org is org-wide. With plain calls:read, call events/history/streams cover only agents the org bound your app to. With calls:read:org your app receives every agent's call events (bound or not, including agents created after install) and can list the whole org's history — that's the scope an analytics dashboard needs. It's a broad grant, so the consent dialog calls it out explicitly; declare it only when org-wide visibility is genuinely the product.
  • calls:read:org does NOT widen declarative postCall capture. Everything the scope grants is a read — events you receive, history you can list. postCall is a standing write into your own objects, so it stays scoped to agents the org bound you to, even when the same install could read those calls anyway. An org-wide analytics app therefore gets call.completed for every agent, but stores rows only for bound ones. If you want capture on an agent, ask to be bound to it; the scope alone won't do it.
  • http:<host> is an allowlist, not a wildcard-everything. Declare each host (or one leading-wildcard pattern). The proxy refuses any host you didn't list.
  • whatsapp:web widens a list you already have. whatsapp.channels() works without it, but returns Cloud API channels only. An org whose only channel is a paired WhatsApp Web session sees an empty picker and concludes your app is broken. Branch on channel.kind'web' sends plain text any time; 'cloud' needs an approved template outside the 24-hour window. They are not interchangeable.
  • ai:* spends the org's money. The App AI Gateway bills the installing org's wallet per call. Declare only the modality you use, and expect an admin to ask what it costs.

links:write mints a tokenized URL at https://<your-dashboard>/l/<token> that someone outside the org can open with no account — a candidate taking an interview, a customer confirming a delivery, a hiring manager reading a shortlist.

const link = await telenow.links.mint({
  action: 'agent_call',          // 'agent_call' | 'form' | 'page'
  targetType: 'candidate',
  targetId: row.id,
  singleUse: true,
  code: '481027',                // optional second factor, sent separately
  ttlSecs: 7 * 24 * 3600,
  opensAt: '2026-08-11T11:15:00Z',
  context: { slug: agentSlug, variables: { candidate_name: 'Navin' } },
});
// link.url  ← the ONLY time the token is returned. Store it or lose it.

What the recipient sees, per action:

actionThe page shows
agent_calla web-call widget bound to context.slug, with context.variables handed to the agent
formfields you declared in context, submitted back to your app
pageread-only context.sections — headings, text, labelled rows

Rules worth knowing before you build on it:

  • context is captured server-side at mint. The recipient's browser cannot supply or alter it — that is the whole point. The same data in a query string would be spoofable.
  • The token is returned once. links.list() never returns tokens. Store link.url on your own row; re-minting hands out a second link and silently kills the first.
  • context is public. It is served to an unauthenticated page. No secrets, no internal ids you wouldn't hand a stranger.
  • Mint before you revoke. When replacing a link, record the new one first — minting can fail, and a recipient holding nothing is worse than one holding a stale link.
  • Links resolve on your dashboard's origin, not the API host. /l/:token is a frontend route.

http:<host> wildcard semantics (read this carefully)

The exact matching rule decides whether a request is allowed. The matcher is case-insensitive and trailing-dot tolerant (a host like api.example.com. is normalised to api.example.com).

A http:*.example.com wildcard scope:

Request hostMatched by http:*.example.com?Why
foo.example.com✅ yesa strict subdomain
a.b.example.com✅ yesany deeper subdomain
example.comnothe apex is not a subdomain
evil-example.com❌ nothe char before example.com must be a literal ., not -
notexample.com❌ nosame — no . boundary

The rule in words: the request host must end with .example.com — i.e. the character immediately before the suffix is a literal dot. A lookalike suffix like evil-example.com is rejected because there is no dot boundary.

An exact scope like http:api.example.com matches only that exact host.

If your integration also calls the apex domain, declare BOTH. Many vendors serve their API on the apex and on subdomains. To call salesforce.com and login.salesforce.com, declare:

"scopes": ["http:salesforce.com", "http:*.salesforce.com"]

http:*.salesforce.com alone will not let you reach salesforce.com.

A host you call but never declared fails with:

host `<host>` is not in the app's granted http: scopes   (403)

RBAC is UI gating only

Your UI can read the current user and their role:

import { useUser } from 'telenow/react';

function NewAgentButton() {
  const { user, can } = useUser();          // user = { id, role, permissions[], name?, email? }
  if (!can('agents:create')) return null;   // hide the button for non-admins
  return <button onClick={buildAgent}>Create agent</button>;
}

user.role, user.permissions[], and the derived can(permission) helper exist so you can show the right UI — hide an admin-only button, grey out a control. That is all they do.

Real permission enforcement is server-side, on every relayed call. When your iframe calls telenow.agents.createFromTemplate(...) or telenow.calls.initiate(...), the dashboard re-checks the user's actual role and your app's scopes before doing anything. A user who edits your bundle in DevTools to force can() to return true gains nothing — the server says no. So treat can() as a UX convenience, never as a security boundary.

(user.name and user.email are only populated when you hold user:profile. Without that scope you still get user.id and role.)

Static app key vs OAuth token — scope enforcement differs per route

The app-key REST API (app-backend) can be called two ways, and they are scoped differently per route. This catches people: a static key skips the Data-API CRUD scope checks entirely, but still gets checked on Files and the live-stream ticket.

  • Static app key (app.scopes is None): bound to one (org, app). Its boundary is the tenant+app binding itself.
  • OAuth Bearer token: carries a scope claim, and every route hard-checks that claim.
Route groupStatic app keyOAuth token
Data API CRUD (GET/POST/PATCH/DELETE /api/app-data/...)always alloweddata:read / data:write are not enforcedhard-checks the token's data:read / data:write claim
Files API (/api/app-files/...)checks the install's declared scopesfiles:read / files:writehard-checks the token's files:* claim
Calls API (GET /api/app-calls, GET /api/app-calls/:id)checks the install's declared calls:read / calls:read:orghard-checks the token's calls:read / calls:read:org claim
Live-stream ticket (POST /api/app-calls/:id/stream-ticket)checks the install's declared calls:read / calls:read:org (plus agent binding, below)hard-checks the token's calls:read / calls:read:org claim

The reason: Data-API CRUD was never scope-gated for static keys (a static key is already tenant+app bound), whereas Files and the live-stream ticket were added later with a stricter, scope-gated gate that applies even to static keys.

The two distinct 403 strings tell you which path failed:

token does not include the <scope> scope     // an OAuth token is missing the scope
app did not declare the <scope> scope         // a static key's install never declared the scope

Live-stream ticket has four gates

POST /api/app-calls/:sessionId/stream-ticket is the strictest Data-API route. In order:

  1. calls:read or calls:read:org — enforced even for a static key (it's a static_install_check=true route).
  2. The call must belong to the key's org, else 404 "call not found".
  3. The install must be BOUND to the call's agent — unless it holds calls:read:org, which covers every agent — else 403 "app is not bound to this call's agent".
  4. The call must be live, else 409 "call is not live".

On success it returns { ticket, wsUrl } — a single-use, 30-second ticket. Connect to wsUrl (/ws/live-call-stream?ticket=…) immediately.

The security model

Everything below is enforced by the platform, not by your code. You get these guarantees for free; you cannot turn them off, and you don't need to re-implement them.

Sandboxed iframe, no tokens in the browser

Your built UI runs in a sandboxed iframe on an opaque origin. It cannot read the dashboard's cookies or auth tokens, and there are no API keys in the browser. The dashboard injects window.telenow; each call is relayed to the parent window, performed under the signed-in user, scoped to your app and the current org, and enforced server-side. If a host is older than a bridge feature, hooks degrade gracefully (empty result or a clear rejected promise) — they never blank-screen.

App keys are tenant + app bound

An app key (used by an external backend) is bound to exactly one (org, app). Every Data/Files/Agents/Campaigns call it makes is automatically scoped to that pair — you cannot reach another tenant's or another app's data, because the app id is never taken from the request path. Mint keys and read your signing secret from your installed app's API keys screen.

The HTTP proxy

When your UI or a tool calls a third-party API, it goes through the dashboard's server-side proxy (telenow.http(...) / useHttp()), never directly from the iframe. The proxy is deliberately strict.

Request contract

  • Host-allowlisted — only hosts in your http:<host> scopes (leading wildcard allowed, per the rules above).
  • HTTPS-only — no plaintext; SSRF-guarded and DNS-pinned, so internal/loopback/metadata addresses are blocked and a target can't pivot into Telenow's network.
  • No redirects — a 3xx is not followed, so an allowlisted host can't bounce you to a forbidden one.
  • MethodsGET | POST | PUT | PATCH | DELETE | HEAD. Anything else is rejected with 400 "method <M> not allowed".
  • Forbidden request headers are silently dropped (you cannot set them): host, content-length, connection, transfer-encoding, keep-alive, upgrade, proxy-authorization, proxy-connection, te, trailer.
  • Body — a JSON value is sent as application/json; a string body is sent verbatim (you control the content type via headers).
  • Bring-your-own Authorization — an Authorization header you pass is forwarded to the allowlisted host and never stored by the proxy. It is overridden if you also use a connection or a secret-header Authorization (those win — see below).

Response contract

  • ≤ 1 MB — the body is capped and returned as text; over-cap fails with 413 "response exceeds 1 MB".
  • Set-Cookie and hop-by-hop headers are stripped from the response.
  • Response header names are lowercased. Read body.headers['content-type'], not 'Content-Type'.
  • Returned shape: { status, headers, body } (inside the standard { success, data } envelope).
"scopes": ["http:api.hubspot.com", "http:*.googleapis.com"]

The proxy is rate-limited per org and circuit-broken per host — a flaky upstream trips the breaker instead of dragging down the dashboard. See Limits & quotas for the exact rate-limit and circuit-breaker numbers.

Stored connections and secret headers

There are two ways to use a credential without ever putting it in your bundle. Both inject the secret server-side, after the host is already allowlisted, so your browser code never sees it.

Stored connectionSecret header
What it injectsthe org's OAuth/API credential for a providerthe app's own encrypted setting value
Who owns the secretthe org admin (via the integrations framework)you (the app), via a secret:true setting
Body fieldconnection: "<provider>"secret_headers: { "<Header>": "<settingKey>" }
Required scopeconnection:<provider>none (but see the exact-scope rule)
Host requirementexactly one of the provider's bound hoststhe host must be granted by an exact http:<host> scope
Always targets headerAuthorizationany header name you choose

Per-install secret headers

secret_headers is a { "<Header-Name>": "<secret setting key>" } map. The browser names only the setting key; the platform looks up that key in your app's own encrypted install config, decrypts it server-side, and sets it as that request header. The value never enters the browser.

// `api_key` is a manifest setting with secret:true — the value lives encrypted,
// server-side. Your code only references the KEY name.
await telenow.http({
  url: 'https://api.example.com/v1/things',
  method: 'GET',
  secret_headers: { Authorization: 'api_key' },   // server decrypts `api_key` → Authorization: <value>
});
// manifest: the secret setting + the EXACT host scope
"settings": [
  { "key": "api_key", "label": "API key", "type": "secret", "required": true }
],
"scopes": ["http:api.example.com"]   // EXACT — not http:*.example.com

Rules — these matter:

  • The value is decrypted and set server-side. Any app-supplied value for the same header name is dropped first (so the injected value isn't duplicated or overridden by browser-side data).

  • Injection is allowed ONLY when the host is granted by an EXACT http:<host> scope. A *. wildcard scope is not enough — a wildcard apex would let the app steer the credential to an attacker-owned sibling host. With only a wildcard scope you get:

    secret injection requires an exact `http:{host}` scope (a wildcard scope is not enough)   (403)
    
  • On decrypt failure the header is silently omitted — never the ciphertext, never a 500. (If you rotate the platform secrets key and an old value can't be decrypted, the call simply goes out without that header rather than failing or leaking ciphertext.)

  • Forbidden proxy header names (see the list above) are dropped even when named in secret_headers.

See the settings form in Dashboard UI and reading secrets server-side in External backends.

Stored connections

A stored connection is a credential the org admin set up once under the dashboard's integrations screen (the OAuth-broker / integrations framework). Your app references it by provider id and the proxy injects the org's stored, auto-refreshed credential as Authorization, server-side.

await telenow.http({
  url: 'https://acme.my.salesforce.com/services/data/v60.0/sobjects/Contact',
  method: 'POST',
  connection: 'salesforce',   // Authorization injected server-side, token auto-refreshed
  body: { LastName: 'Doe' },
});
// declare the connection scope AND the host(s) you call
"scopes": ["http:*.salesforce.com", "http:salesforce.com", "connection:salesforce"]

Provider ids the platform ships (use the exact id): salesforce, hubspot, shopify, stripe, calendly, notion, airtable, google (one connection covers Calendar, Sheets, and Gmail send).

How injection resolves:

  • It picks an active connection first, then the most-recently-updated one.
  • It auto-refreshes an expiring OAuth token before injecting.
  • It injects, in order of what the connection has: Bearer <access_token>Basic <basic>Bearer <api_key> as the Authorization header.
  • A stored connection's Authorization always wins — over any app-supplied Authorization header and over a duplicate Authorization named in secret_headers. Both are dropped so only the connection's credential goes out.

Prerequisites — both must be true:

  1. The org admin has connected the provider under integrations and the connection is active / usable.
  2. The requested host is exactly one of the provider's bound hosts.

The bound-host set is the provider's vendor-fixed proxyHosts (e.g. api.hubapi.com for HubSpot) UNION the org's own host from the provider's proxyHostSetting (e.g. the Salesforce instance_url, like acme.my.salesforce.com). It is a strict allowlist of concrete hosts — never a domain suffix — so an app can't declare http:*.salesforce.com + connection:salesforce and have the token injected into an attacker-owned *.salesforce.com sibling.

Exact errors you'll hit (so you can debug fast):

ErrorStatusCause
app did not declare the connection:<provider> scope403you used connection:'<provider>' without the matching scope
unknown connection provider <provider>``400the provider id isn't one the platform ships
no <provider> connection in this org400the org admin hasn't connected this provider yet
`<provider>` connection has no bound host — its credential can't be injected safely (use bring-your-own-bearer instead)400the provider has no proxyHosts and the org set no host — nothing safe to bind to
connection <provider> may only be used with: <hosts>403your url host isn't in the provider's bound-host set
`<provider>` connection has no usable credential400the connection exists but carries no access token / basic / api key

If a provider's credential genuinely can't be bound to a concrete host, fall back to bring-your-own-bearer (pass your own Authorization) or a secret header against an exact host.

The rule of thumb

Secrets stay on the server. If a value would be dangerous in a user's browser DevTools, model it as a stored connection (org-owned, third-party provider) or a secret setting + secret_headers (app-owned). Never put it in plain UI state.

Manifest hardening (shipped templates are sanitized)

Your app can ship ready-made agents and teams. Because a manifest is untrusted input, the platform strips and sanitizes the parts of a template that could otherwise smuggle in dangerous behaviour. You don't call these functions — they run automatically when an agent or team is built from your template.

Embedded tool defs are stripped (strip_embedded_tools)

Removed from the top level and from every flow node: the tools array, precallLookups, and the editor-scratch flowDraft. The app's real tools are merged at call time from the agent's binding, so an embedded tools array in a template can't introduce an http/SSRF/exfil tool.

Flow-node configs are sanitized (sanitize_template_flow_nodes)

The sanitizer reads each node's kind from kind, falling back to type, defaulting to conversation if neither is present (so a node is defused regardless of which key a hostile manifest used). Then, per node kind:

  • tool node — survives only if it runs your own app's tool. The exact condition is: node.config.kind == "app" AND the nested node.config.config.app_id equals this app's id exactly. Any other tool node — arbitrary http/connector/MCP, or one pointing at a different app's id (cross-app read/write) — loses its config and fails safe. (Note the nested config.config.app_id: it's the inner config that carries the app id.)
  • code node — loses its config. No arbitrary JS the installer never wrote.
  • transfer node — its destination is defused: the top-level number is blanked, every per-destination number is blanked, and per-destination numbers arrays are removed. No attacker-chosen number baked into the flow for toll-fraud or call interception.

On export, no tool nodes survive

When a template is exported (there is no install context, so allow_app is None), no tool nodes are kept at all — the app_own check can never be satisfied without an installing app id. The flow graph itself — prompts, nodes, edges — is preserved; only the dangerous config is defused.

The takeaway: a template can declare deterministic app-tool nodes (running your own tools), but it can never smuggle an http/SSRF/cross-app/code/transfer node into the org.

Inbound hooks authenticate every POST

A platform-hosted inbound hook receives third-party POSTs at /webhooks/app/<installationId>/<hookId> with no dev backend. Each request must authenticate — there is no fail-open path:

  • HMAC via a verify block (e.g. Meta's x-hub-signature-256, verified against the install's signing secret), or
  • an unguessable ?token=<signing_secret> in the URL when no verify is declared.
// clinic-crm: Meta lead-ads → lead object, HMAC verified
"inboundHooks": [
  {
    "id": "lead-intake",
    "object": "lead",
    "key": "phone",
    "map": { "name": "full_name", "phone": "phone_number", "source": "ad_id" },
    "verify": { "header": "x-hub-signature-256", "prefix": "sha256=", "algo": "sha256", "encoding": "hex" }
  }
]

Bodies are capped at 256 KB and the endpoint is rate-limited. A bad signature is rejected with 401 — it never falls through to "accept anyway".

Least-privilege guidance

Declare only the scopes you actually use. This is both good security hygiene and a real adoption lever — many teams in India and abroad have a security or compliance reviewer who reads your scope list before approving an install. A short, justified list installs faster and earns trust; a kitchen-sink list raises questions.

Practical checklist:

  • Request objects:<type> only for the object types your tools and UI touch. Doctor CRM lists patient, appointment, visit — not a blanket grant.
  • Add calls:read only if you actually read history or consume live/mid-call events. Reach for calls:read:org only when org-wide visibility is the product (an analytics dashboard, a QA/compliance monitor) — it's the single broadest data grant an app can ask for, and reviewers will treat it that way.
  • Add http:<host> per host you call; prefer a specific host over a broad *. wildcard when you can — and remember a wildcard scope alone can't carry a secret header (those need an exact host).
  • Use connection:<provider> + secret settings instead of asking users to paste credentials into your UI.
  • Spell every scope exactly — there is no server-side typo guard, so a misspelt scope silently grants nothing and fails at runtime with a 403.
  • Don't pre-declare scopes "just in case" unless re-consent friction genuinely outweighs the smaller ask — every extra scope is something a reviewer has to justify approving.

Next