posthog
posthog.
Run an analytics query — free HogQL, or one of PostHog's named query
setup & usage
create a personal key in PostHog (Settings → Personal API keys — see the API docs), then paste it into oto.
phc_… (the installation and ingestion one): the read API refuses it, with a 401 impossible to tell apart from a dead key. you need the personal key, which starts with phx_. oto refuses a phc_ on entry rather than leaving you to hunt for the causequery:read + project:read; add insight:read, person:read, event_definition:read, property_definition:read, cohort:read, feature_flag:read, session_recording:read, annotation:write depending on what you want to do. a key missing a scope authenticates just fine and fails on the first real call — so the "test the connection" button exercises a real query, not just the identityhttps://us.posthog.com and https://eu.posthog.com are two distinct deployments. a key from one is unknown to the other, and here again the symptom is a 401. pick your project's (or your self-hosted instance's URL)posthog_query(hogql="SELECT count() FROM events WHERE event = 'signup' AND timestamp > now() - INTERVAL 7 DAY")posthog_schema(op="events") — do this before writing a query; op="tables" then op="columns", table="events" for the schemaposthog_insight(op="list") to find it, then posthog_insight(op="run", insight_id=…, date_from="-7d") — the figure returned is the dashboard's, computed by PostHogposthog_query(query={"kind": "FunnelsQuery", …}) — above all not hand-written HogQL for a funnel (see the note below)posthog_person(op="list", search="alice@acme.com") then op="get"posthog_group(op="types") then op="list" — account-level questions cannot be answered with personsposthog_flag(op="list")posthog_recording(op="list", date_from="-7d")posthog_project(op="annotate", content="v2.3 in production")posthog_project(op="current"): which project, which account, which region answered — it is the most frequent explanationPostHog's funnel semantics (ordered or unordered steps, conversion window, exclusion steps, attribution) cannot be faithfully rebuilt in HogQL. a hand-written query will return a plausible number, and it will disagree with the one your team reads in PostHog — the worst outcome, because nothing signals the error.
two correct routes, in this order:
1. the insight already exists → posthog_insight(op="run", insight_id=…), optionally with date_from/date_to to change the window. the definition comes from your team, the computation from PostHog
2. otherwise → posthog_query(query={"kind": "FunnelsQuery" | "RetentionQuery" | "TrendsQuery", …}), which makes PostHog compute
free HogQL remains the right route for everything else: counts, breakdowns, joins, ad hoc questions.
it is ClickHouse SQL with PostHog accessors:
properties.$browser, person.properties.email — no JSONExtract. values are strings: comparing a number requires toFloat(properties.amount) > 10event (not event_name); time is timestamp, filtered by timestamp >= now() - INTERVAL 7 DAYuniq(person_id) — never count(distinct distinct_id), which counts devicesevents, persons, sessions, groups. posthog_schema lists them alla query without LIMIT is bounded to 101 rows by PostHog, with hasMore true: aggregate in the query rather than paginating.
create, edit or toggle a feature flag, write an insight or a cohort, delete a person or a recording, send events: none of these operations exists in the underlying library. toggling a flag changes the product for real users, and deleting a person is irreversible and regulated — it is not an assistant's decision. do them in PostHog.
the only write is the annotation: a dated marker placed on your graphs, purely additive, which modifies no measurement.
tested against a real PostHog Cloud US project: identity, project discovery, HogQL, typed queries, re-running a saved insight, schema (156 tables, events at 52 columns), the 14 resource families and annotation writing respond as coded. three shapes that cannot be deduced from the docs and that are handled here: groups_types returns a bare list (no results envelope), /events/ and /persons/ carry no count (never announce a total from a page — go through a query), and the raw /query/ response is 93% internal diagnostics (generated SQL, modifiers, cache keys), reduced here to the columns, types, results and the query actually executed.
this connector accepts multiple comptes: each stored credential becomes a named account (one name per compte), at your level, your team's or your org's.
_account="<name>" on the tool; list them: oto_identity(op='list', connector='posthog') (scope='org' or scope='group' for the org's or team's)oto_identity(op='set', connector='posthog', identity_id='<name>'); rename: op='rename' with new_name_account) and adding upoutils
usage
claude plugin marketplace add otomata-tech/oto-plugin — mcp + skill configured.pipx install oto-cli then oto posthog …