posthog/ai-plugin-setting-up-a-custom-rest-source
> Connect an arbitrary REST API to the PostHog data warehouse as a Custom source by authoring a JSON manifest, with no per-source code. Use when the user points at an API that has no built-in PostHog connector — "import data from this REST API", "sync my internal API", "connect this API from its docs", "build a custom data warehouse source" — and gives a docs URL or a natural-language description of the endpoints. Walks through drafting the RESTAPIConfig manifest (auth — bearer, API key, HTTP basic, or OAuth2 client credentials / refresh token — pagination, record path, incremental cursor, parent/child fan-out), validating it, test-reading live rows to verify the field mappings, and creating the source. If the API already has a native PostHog connector, use setting-up-a-data-warehouse-source instead — this skill checks the connector registry first and only handles APIs with no native connector.
npx skills add https://github.com/PostHog/ai-plugin --skill setting-up-a-custom-rest-source
A Custom source imports any HTTP REST API into queryable warehouse tables from a JSON manifest — no
per-source Python. The manifest is a RESTAPIConfig: the same shape that powers PostHog's built-in REST connectors
(Intercom, Attio, Sentry, …), so the generic REST engine handles auth, pagination, JSONPath record extraction, and
incremental cursors for you. Your job is to author a correct manifest and prove it against live data before creating
the source.
This is an alpha capability. Caps: at most 50 resources per manifest, and at most 5 Custom sources per project.
API — and can give you its docs URL or describe its endpoints.
A Custom source is the fallback — only correct when no native connector fits. PostHog ships hundreds of native
connectors, far more than could be listed here, so never assume an API has no built-in — most well-known SaaS apps
and databases do, and guessing wrong misroutes them into a hand-authored manifest that duplicates a battle-tested
connector. Step 0 below makes you check the registry before drafting anything; if a native connector matches, hand off
to setting-up-a-data-warehouse-source instead.
Read references/manifest-reference.md before drafting — it is the full
RESTAPIConfig field reference (auth types, the six paginators, incremental cursors, parent/child fan-out) with worked
examples for each. Draft the manifest from that grammar; don't guess field names.
The skeleton:
{
"client": {
"base_url": "https://api.example.com/v1",
"auth": { "type": "bearer" }
},
"resources": [
{
"name": "users",
"primary_key": "id",
"endpoint": {
"path": "/users",
"data_selector": "data",
"paginator": { "type": "json_response", "next_url_path": "next" },
"incremental": { "cursor_path": "updated_at", "start_param": "since" }
}
}
]
}
Secrets never go inline in the manifest. manifest_json holds only the non-secret structure. The credential
travels in a separate payload key chosen by the manifest's client.auth.type: auth_token (bearer), auth_api_key
(api_key), auth_password (http_basic), or auth_oauth2_client_secret for oauth2 (plus
auth_oauth2_refresh_token for the refresh-token grant only). The
engine injects it at run time, and PostHog redacts it from every response. Putting a token inline is rejected at
validation.
| Tool | Purpose |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| external-data-sources-wizard | List every native source type PostHog supports. Run this first (Step 0) to check whether the target API already has a built-in connector before drafting a manifest. |
| external-data-sources-db-schema | Validate the manifest + credential and list the resources (tables) it exposes, with detected primary keys and incremental cursors. This is the validate-and-list step. |
| external-data-sources-preview-resource | Read a small live sample of rows for one resource — verify data_selector / primary_key / cursor_path against real data before creating anything. |
| data-warehouse-source-setup | Create the source. Enables all manifest resources with sync defaults in one call. |
| external-data-sources-create | Advanced create — lets the user hand-pick which resources sync via a schemas array. |
| external-data-schemas-list | After creation, watch per-table sync status. |
Before drafting anything, call external-data-sources-wizard to list the native source types and check whether the
target API is among them, matching on the service name. A Custom source is the fallback for APIs with no native
connector; do not skip this check on the assumption that a well-known API isn't supported — most are.
If a native connector matches, stop and tell the user the built-in path is simpler and battle-tested (it handles
auth, pagination, schema, and incremental sync for you), and hand off to setting-up-a-data-warehouse-source. Only
continue with this skill when the user has no matching native connector, or explicitly wants to exercise the custom
REST path despite one existing.
Get either a docs URL (fetch it and read the auth scheme, the list endpoints, their response envelopes, and any
pagination) or a natural-language description of the endpoints. You need, per resource you'll import:
base_url) and method (GET, or POST for query-style read endpoints),client_credentials or a pre-obtained refresh token; the interactive authorization_code flow is not supported),
data, results, items),updated_at-style) so re-syncs only fetch new/changed rows.Ask the user for the credential value, but tell them you'll only ever place it in the auth_* payload key, never in
the manifest.
Author the RESTAPIConfig from references/manifest-reference.md. Match the auth
block to the scheme, pick the paginator that matches the docs, set data_selector to the record path, and add an
incremental block when the API has an updated_at-style cursor and a matching query param. For an endpoint whose
rows must be fetched per parent (e.g. /forms/{form_id}/responses), use a parent/child resolve param — see the
fan-out example. Keep it to one level of nesting.
Call external-data-sources-db-schema with `{ source_type: "Custom", manifest_json: "<stringified manifest>",
auth_token: "<credential>" }. The credential key is not literally auth_*` — use the one for your auth type:
auth_token (bearer), auth_api_key (api_key), auth_password (http_basic), or auth_oauth2_client_secret (+
auth_oauth2_refresh_token for the refresh-token grant). It validates the manifest structure,
the fan-out graph, and the credential (a bounded live probe),
then returns one table entry per resource with detected_primary_keys and incremental_fields. If it returns a 400,
the message is plain English (e.g. resources[0].endpoint.path: must not be empty) — fix the manifest and retry.
Loop here until it validates.
For each resource, call external-data-sources-preview-resource with `{ source_type: "Custom", payload: {
manifest_json, auth_token }, resource_name: "<name>", limit: 10 } (the auth_token` key varies by auth type, as in
Step 3). It returns up to limit real rows plus the inferred
columns. Check that:
data_selector is right — rows are the records you expect, not a wrapper object. If rows looks like[{ "data": [...] }] you pointed at the envelope, not the array; fix data_selector.
primary_key exists in the rows and is unique.cursor_path field is present in the rows and looks like a sortable timestamp/id.A live failure (unreachable host, auth rejected) comes back as error with empty rows — fix credentials or the URL
and retry. Iterate Steps 2–4 until the sample looks right.
Call data-warehouse-source-setup with `{ source_type: "Custom", payload: { manifest_json, auth_token }, prefix:
"<short_name>" } (the auth_token` key varies by auth type, as in Step 3). It enables every resource in the manifest with sensible sync defaults (incremental where the
manifest declares a cursor, else full refresh) and creates the source. If the user only wants a subset of resources,
use external-data-sources-create with a schemas array instead (see setting-up-a-data-warehouse-source for the
schemas shape). Pick a short lowercase prefix — tables become {prefix}_{resource_name} in HogQL.
After creation, call external-data-schemas-list to show the user the initial sync status, and tell them how to query:
SELECT * FROM {prefix}_{resource_name} LIMIT 10.
field mappings (data_selector / primary_key / cursor_path) against real rows. Skipping preview is the most
common way to create a source that syncs zero or malformed rows.
auth_*. Never inline a token/key/password in manifest_json — it's rejected, and the manifestis non-secret (it round-trips to the client).
source API.
updated_at-style field over created_at (it catches edits), and setcursor_type when the cursor isn't a datetime (e.g. an integer id) so it's compared with the right type.
call, and any rotated single-use refresh token is persisted server-side — so keep the entire client.auth block
identical across those calls within one setup, and re-submit the same secrets each time. Changing any auth-block
field mid-setup discards the stored rotation, and providers that rotate single-use refresh tokens will then reject
the next mint until the user fetches a fresh token. Never set auth_oauth2_integration_id yourself (it is server-owned); to
reconnect a source whose token broke, update it with re-entered auth_oauth2_client_secret /
auth_oauth2_refresh_token. See the OAuth2 section of the manifest reference for the auth block fields.
Take posthog/ai-plugin-setting-up-a-custom-rest-source from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.