Sources
What a key can be bound to, and how to set each one up.
Sources
What bind_source can point a key at. Every source is read-only: nothing here ever writes back to what it reads. Every credential is encrypted at rest and never returned in a result or a log.
Every source answers the same way. One record comes back resolved. Several that disagree come back ambiguous, with every candidate. None, or a source that doesn't answer (a network error, a timeout, a non-2xx status), comes back unavailable, never a guess dressed up as an empty answer.
A key isn't limited to one source. Pass alongside: true to bind_source to bind a second system to a key that already has one — up to four. When the bound systems agree, the key still answers resolved; when they disagree, it answers ambiguous with a candidate for each, labelled by system (the connection name, or the endpoint's own host) — so whoever decides sees "Orders says $412.00, Payments says $389.00" instead of one unattributed number. A plain bind_source call with no alongside still replaces the one source a key had, exactly as before.
A plain endpoint
{ key, url, headers?, field? } — url must be https://; a plain http:// endpoint is refused before anything is bound. headers are sent exactly as given on every read (an auth token, typically) — up to 16, names up to 64 characters, values up to 2,048. Host, Content-Length, Transfer-Encoding, Connection and other hop-by-hop headers are refused, since the connection sets them. If your endpoint redirects to a different origin, none of your headers go along.
Without field, the key answers with the whole response. To answer with one value inside it, add field: a dotted path of object keys and array indexes, such as status or data.items.0.total — up to 16 parts. It's stored with the binding, like headers.
{ "key": "order.99.status",
"url": "https://api.example.com/orders/99.json",
"field": "status"
}
If the endpoint returns { "id": 99, "status": "shipped", "name": "Order 99" }, recall_live_fact on the key answers:
{ "status": "resolved", "value": "shipped", "as_of": "…" }
The value comes back as it is — text, a number, an object or an array. If the path isn't there, you get unavailable with reason field_missing and a detail that names the missing part, for example field data.items.0.total: no 'items' at data. A response that isn't JSON is unavailable too, with not_json. So is a null at the path, with field_null: a value that says nothing isn't a fact. Evidence mode fingerprints the value at the path, not the whole response.
Not sure which path to use? discover_fields lists the fields in the response and their types, never their values. Pass field to list only what's under that path.
A named source kind
Use { key, source: { kind, …fields } }:
| kind | Fields | Reads |
|---|---|---|
postgrest | base_url (https), table, key_column, value_column, api_key, lookup? | A Postgres/Supabase table via PostgREST. |
airtable | base_id, table, key_field, value_field, token | An Airtable table. |
gsheets | sheet_id, range, key_column_index, value_column_index, api_key | A Google Sheet's values — public-or-API-key sheets only. |
hubspot | object (contacts | companies | deals), search_property, value_property, token | A HubSpot CRM search. |
mcp | url (https), tool, headers?, arg_name?, result_path?, lookup? | A tool on another MCP server — any MCP server can be an Ogen source this way. |
salesforce | instance_url (https, e.g. https://<org>.my.salesforce.com), client_id, client_secret, soql, field, api_version? (default v61.0) | A Salesforce SOQL query — a Connected App's client-credentials sign-in, then the query, expected to return one record; reads field off it (dotted paths reach a relationship field, e.g. Owner.Name). The sign-in happens fresh on every read; nothing is kept. |
netsuite | account_id (e.g. 1234567 or 1234567_SB1), consumer_key, consumer_secret, token_id, token_secret, suiteql, field | A SuiteQL query over NetSuite's REST query service with Token-Based Authentication, signed fresh on every read; expected to return one row; reads field off it. |
postgres | connection_string (postgres:// or postgresql://, must set sslmode=require or verify-full), sql (a SELECT or WITH…SELECT expected to return one row), field (dotted path for a json/jsonb column), timeout_ms? (default 5000, max 10000), params? (positional values for $1..$n) | A raw Postgres database (Neon, RDS, Supabase's direct connection, your own): runs sql inside a read-only transaction and reads field off the one row it returns. |
priority | base_url (https, the OData root including the company, e.g. https://host/odata/Priority/tabula.ini/COMPANY), username + password (HTTP Basic — a Priority user or Priority's own application credentials, sent the same way), form (e.g. ORDERS, PORDERS, PART, DOCUMENTS), filter (an OData $filter expected to match one row), field, select?, expand?, timeout_ms? (default 8000, max 15000) | Priority ERP over its own OData REST interface: reads field off the one row filter matches on form. |
Unlike the other kinds, salesforce, netsuite, postgres and priority take their query in full from the bound config — the Ogen key is only the name the fact is filed under.
The lookup value is the part of the key after its last dot (order.99 looks up 99) unless lookup: "full_key" is set on a postgrest or mcp config.
Set up Salesforce
A click path an org admin can follow in about ten minutes, then the bind.
- Setup → App Manager → New Connected App (or External Client App Manager on newer orgs — or the equivalent under Setup).
- Give it a name and a contact email, enable OAuth settings, set a callback URL (any
https://URL — Ogen never uses it for this flow), and add the scope "Manage user data via APIs (api)." - Enable Client Credentials Flow.
- Save, then Manage → Edit Policies, and set Run As to an integration user with read access to whatever objects Ogen will query.
- Important: if Permitted Users is "Admin approved users are pre-authorized," that Run As user's profile (or a permission set) also has to be added under Profiles / Permission Sets on the app itself — skip this and the token endpoint answers
invalid_app_access: user is not admin approved. - Manage → Manage Consumer Details → copy the Consumer Key and Consumer Secret.
- Note the instance URL — the org's My Domain:
https://<mydomain>.my.salesforce.com.
Use a dedicated, read-only integration user on a minimal profile for Run As — not an admin's own login.
Then bind it:
{ "key": "case.00012345.status",
"source": {
"kind": "salesforce",
"instance_url": "https://yourorg.my.salesforce.com",
"client_id": "<consumer key>",
"client_secret": "<consumer secret>",
"soql": "SELECT Status FROM Case WHERE CaseNumber = '00012345'",
"field": "Status"
}
}
Check it: call recall_live_fact on the new key. unavailable with a reason means a credential or permission from the steps above is off. Not sure what the record's field names are? discover_fields reads it once and shows you the field names before you name more keys.
Set up NetSuite
A click path an account admin can follow in about ten minutes, then the bind.
- Setup → Company → Enable Features → SuiteCloud (or the equivalent under Setup) → enable Token-Based Authentication and REST Web Services.
- Setup → Integration → Manage Integrations → New. Name it (e.g. "Ogen"), check Token-Based Authentication, and leave TBA authorization flow / OAuth 2.0 unchecked.
- Save, and copy the Consumer Key and Consumer Secret — shown once, so store them now.
- Assign a role carrying "Log in using Access Tokens," "REST Web Services," and "SuiteAnalytics Workbook" (needed for SuiteQL), plus read permission on whatever records Ogen will query.
- Setup → Users/Roles → Access Tokens → New. Pick the integration you just made, the user, and that role.
- Save, and copy the Token ID and Token Secret — also shown once.
- Note the account_id — Setup → Company → Company Information → Account ID (a sandbox carries a suffix such as
_SB1).
Then bind it:
{ "key": "customer.4412.balance",
"source": {
"kind": "netsuite",
"account_id": "1234567",
"consumer_key": "<consumer key>",
"consumer_secret": "<consumer secret>",
"token_id": "<token id>",
"token_secret": "<token secret>",
"suiteql": "SELECT balance FROM customer WHERE id = 4412",
"field": "balance"
}
}
Check it: call recall_live_fact on the new key. unavailable with a reason means a credential or permission from the steps above is off. Not sure what the row's field names are? discover_fields reads it once and shows you the field names before you name more keys.
Set up Postgres
Any Postgres you can reach over TLS — Neon, RDS, Supabase's direct connection, a database of your own. Three steps, then the bind.
- Create a dedicated read-only role for Ogen:
GRANT CONNECTon the database andSELECTon the tables it will read — never write privileges. - Build that role's connection string with
sslmode=require(orverify-fullto pin the certificate). A plain, unencrypted connection is refused. - Write
sqlas a single SELECT expected to return exactly one row. It always runs inside a read-only transaction, so even a mistaken write verb is rejected by Postgres itself.
Then bind it:
{ "key": "order.99.status",
"source": {
"kind": "postgres",
"connection_string": "postgresql://ogen_ro:<password>@db.example.com:5432/app?sslmode=require",
"sql": "SELECT status FROM orders WHERE id = $1",
"params": [99],
"field": "status"
}
}
Or store the connection string once with connections (action "set") (kind postgres) and pass "connection": "<name>" instead.
Check it: call recall_live_fact on the new key. unavailable with a reason means the role, the TLS setting or the query is off — the reason names which, never the SQL or the credential.
A query that matches more than 1,000 rows, all with the same value, comes back unavailable with reason row_cap and a detail naming the limit — narrow it so it matches one record.
Set up Priority
A click path a Priority administrator can follow, then the bind.
- Create a dedicated, read-only Priority user for Ogen — or, better, one of Priority's own application credentials (issued for integrations rather than a person); either kind of credential is sent the same way and works unchanged below.
- Decide which forms that user needs to read (e.g.
ORDERSfor sales orders,PARTfor parts,DOCUMENTSfor documents) and give it read access to those, and nothing more. - Find the OData base URL under Priority's web/REST settings — it carries the company, e.g.
https://<host>/odata/Priority/tabula.ini/<COMPANY>.
Then bind it:
{ "key": "order.SO26000123.status",
"source": {
"kind": "priority",
"base_url": "https://host.example.com/odata/Priority/tabula.ini/COMPANY",
"username": "<user or application credential>",
"password": "<password>",
"form": "ORDERS",
"filter": "ORDNAME eq 'SO26000123'",
"field": "ORDSTATUSDES"
}
}
Or store the base URL and credential once with connections (action "set") (kind priority) and pass "connection": "<name>" plus form/filter/field instead.
Check it: call recall_live_fact on the new key. unavailable with a reason means the credential, the form permission or the filter is off — the reason names which, never the credential itself. Not sure what the row's field names are? discover_fields reads it once and shows you the field names before you name more keys.
Any system with an MCP server
The mcp kind treats one tool on another MCP server as a source. When recall_live_fact asks, Ogen opens a fresh Streamable HTTP connection to the url you bound, calls the tool you named with the looked-up value as its argument, and reads back what it returns: no session held, nothing cached. The credentials that call needs (a bearer token, an API key, whatever header the far server wants) are stored like every other source credential on this page. Ogen never lists, changes or calls anything else on that server.
For example, binding a key to one Jira issue's status via Atlassian's own hosted MCP server:
{ "key": "ticket.4412.status",
"source": {
"kind": "mcp",
"url": "https://mcp.atlassian.com/v1/mcp",
"tool": "getJiraIssue",
"headers": { "Authorization": "Bearer <atlassian-oauth-token>" },
"arg_name": "issueKey",
"result_path": "fields.status.name"
}
}
{ "bound": true, "key": "ticket.4412.status", "kind": "mcp", "label": "mcp.atlassian.com/getJiraIssue" }
Servers people bind today
Any Streamable HTTP MCP server works. These are the systems people ask for by name:
| System | MCP server | Auth | Example tool for a fact |
|---|---|---|---|
| Salesforce | Community servers — no single official hosted endpoint yet | OAuth 2.0 (connected app) | A tool returning one Case's Status field |
| HubSpot | Official first-party server — but Ogen also ships hubspot as a named kind; bind that directly instead of going through mcp | Private app token or OAuth | A tool returning one Contact's lifecycle stage |
| Zendesk | Community servers | API token or OAuth | A tool returning one ticket's status |
| Jira / Confluence | Atlassian's hosted Remote MCP Server — mcp.atlassian.com | OAuth 2.0 (Atlassian account) | A tool returning one issue's status field |
| Stripe | Stripe's hosted MCP server — mcp.stripe.com | API key (a restricted key) or OAuth | A tool returning one customer's balance |
| Notion | Notion's hosted MCP server — mcp.notion.com | OAuth 2.0 (Notion workspace) | A tool returning one page property's value |
| Linear | Linear's hosted MCP server — mcp.linear.app | OAuth 2.0 (Linear workspace) | A tool returning one issue's state |
| GitHub | GitHub's hosted MCP server — api.githubcopilot.com/mcp | OAuth, or a fine-grained personal access token | A tool returning one pull request's review status |
| Slack | Community servers — no widely-adopted official hosted endpoint yet | OAuth 2.0 (Slack app) or bot token | A tool returning one channel's latest message |
| Google Drive / Sheets | Community servers — for sheet values, Ogen's first-class gsheets kind is the simpler path | OAuth 2.0 (Google account) | A tool returning one file's last-modified time |
| Supabase / Postgres | Supabase's own server runs locally over stdio, not a public hosted HTTP endpoint — Ogen's first-class postgrest kind reads a Supabase/Postgres table directly and is the simpler bind | Service-role or anon API key (postgrest) | One table row's single column |
| Shopify | Community servers | Admin API access token or OAuth | A tool returning one order's fulfillment status |
| Intercom | Community servers | API access token or OAuth | A tool returning one conversation's state |
| NetSuite | Community servers (SuiteTalk-backed) | Token-based authentication (TBA) or OAuth 2.0 | A tool returning one customer record's balance |
Any tool that returns the same shape each time — one record, one field, one status — can be a fact, whichever server happens to answer it. Where a system has both a first-class kind and an MCP server, as HubSpot, Google Sheets, and Supabase/Postgres do above, the first-class kind is the simpler bind.
Credentials
Whatever you give a source to read with — an API key, a token, a header — is encrypted at rest (AES-256-GCM) the moment it's stored and exists in plaintext only for the instant of the outbound read. bind_source's result never carries it back, and it's never written to a log.
Connections
For Salesforce, NetSuite, HubSpot, Airtable and PostgREST/Supabase sources, store one credential set once, under a name, with connections (action "set"). Any key you bind with source: { kind, connection: "<name>", … } reads through that connection instead of repeating the credential inline. Rotating a credential is one call — connections with action "set" again, with the same name — and every key bound to it uses the new credential on its very next read, nothing to re-bind. connections (action "list") shows your connections by name, kind and label, never a credential; connections (action "remove") is refused while any key still depends on it, naming which ones. Its "set" and "remove" actions require an owner key or a signed-in person — a plain operator key is refused with "this key is an operator; ask an owner, or sign in"; "list" stays open to any role. See Roles.