Title Movement Skill
Detect which job titles are newly entering or permanently leaving a company, month by month. Data source: the extended employment-history pool — entity experiences_pool (alias ONFIRE.EXPERIENCES_FULL) via ask_onfire. This is the broad superset (~5x) of the curated people_experiences, one row per person-stint, and is the right source for whole-pool org movement because it covers people regardless of whether they carry curated insights.
experiences_poolis GATED. It is withheld from the routing catalog and has no insight search. You must name the entity explicitly and setallow_extended_pool: truein every QueryIR below, or the query will not run. The narrower curated alternative ispeople_experiences— it covers only insight-connected people (a fraction of the pool), so preferexperiences_poolhere for complete movement coverage; fall back topeople_experiencesonly if a caller explicitly wants the curated subset.
How this skill works in two parts. ask_onfire takes a structured QueryIR, not SQL. Two of its capabilities do most of the work for us:
- Per-month counts run inside
ask_onfire(aggregate mode). The month-by-month new-hire and departure counts by title are now AGGREGATE-MODE QueryIR queries —ask_onfirebucketsstart_date/end_dateby month, groups by title, and counts distinct people server-side. No raw rows are pulled for this, and no client-side bucketing is needed. - The "first-ever / now-gone" flags stay client-side (Step 4). Whether a title is genuinely new to the company (first-ever appearance) or fully gone (nobody currently holds it) needs a window/self-join over the title's full history. That is not expressible in a QueryIR (confirmed by the entity's own caveats), so it is computed by the agent over one small bounded
query_datasetspull.
So the flow is: two aggregate ask_onfire calls for the monthly hire/departure counts (Step 2 + Step 3), one tiny bounded pull for the flags (Step 4), then merge and format.
Step 1 — Parse inputs
| Parameter | Default | Notes |
|---|---|---|
company_linkedin_url |
required | e.g. linkedin.com/company/taskboard. Infer slug from company name if needed (e.g. "Cloudforce" → linkedin.com/company/cloudforce). State the inferred URL explicitly. Passed as a company_url filter value (any URL format is normalized server-side). |
start_date |
12 months ago | Convert natural language: "2 years" → 24 months ago, "since Jan 2024" → 2024-01, "6 months" → 6 months ago. Format: YYYY-MM. Applied as a client-side cutoff on the returned month buckets — not as a query filter (start_date / end_date on the entity are TEXT 'YYYY-MM', not real dates, so range filtering them in the query is unreliable). |
title_filter |
none | Translate "only engineering titles" or "titles with manager" to a dimension filter: {dimension: "title_name", op: "contains", value: "engineer"} (title_name is free text; contains is a case-insensitive substring match). One keyword per query. Multiple OR'd keywords are NOT expressible in a single QueryIR (dimension filters AND together, and there is no OR) — run one set of calls per keyword and merge the groups client-side. |
group_grain |
title_role |
The title axis to group hires/departures by. title_role is the closed, normalized role taxonomy (cleaner buckets, fewer spelling variants); title_name is the free-text raw title. Group by title_role by default; switch to (or add) title_name when the user wants the raw titles. |
show_all |
false | Default: in the final table (Step 5), only keep months/titles where title_new_to_company = TRUE OR title_gone_from_company = TRUE. If user says "show everything" or "all movement", keep every month/title bucket. |
Step 2 — New hires per month by title (aggregate mode, ask_onfire)
Hires = a month-bucket over start_date, grouped by month + title, counting distinct people. This runs entirely inside ask_onfire — it returns one row per (month, title) group, not raw stints.
ask_onfire(query={
entity: "experiences_pool",
allow_extended_pool: true,
filters: [
{dimension: "company_url", op: "eq", value: "<company_linkedin_url>"}
/* optional single-keyword title filter:
{dimension: "title_name", op: "contains", value: "<keyword>"} */
],
buckets: [{name: "hire_month", field: "start_date", part: "month"}],
group_by: ["hire_month", "title_role"],
aggregations: [{name: "hires", fn: "count_distinct", field: "person_url"}],
order_by: [{field: "hire_month", direction: "desc"}],
limit: 200
})
Result columns: hire_month, title_role, hires. (Swap title_role → title_name — in both group_by and order_by if you order on it — to group by the raw title; add both to group by each.)
Step 3 — Departures per month by title (aggregate mode, ask_onfire)
Departures = the same query bucketed on end_date instead of start_date. A non-empty end_date is a stint that ended (a departure from that role); ongoing stints have NULL/empty end_date and simply don't bucket.
ask_onfire(query={
entity: "experiences_pool",
allow_extended_pool: true,
filters: [
{dimension: "company_url", op: "eq", value: "<company_linkedin_url>"}
/* same optional single-keyword title filter as Step 2 */
],
buckets: [{name: "depart_month", field: "end_date", part: "month"}],
group_by: ["depart_month", "title_role"],
aggregations: [{name: "departures", fn: "count_distinct", field: "person_url"}],
order_by: [{field: "depart_month", direction: "desc"}],
limit: 200
})
Result columns: depart_month, title_role, departures.
Notes on Steps 2–3
- Scope to the company.
company_url eq <slug>keeps each query small and on-topic. Any LinkedIn URL format is normalized server-side. allow_extended_pool: trueis mandatory on both calls —experiences_poolis gated and the query will not run without it.- No date filter in the query.
start_date/end_dateare TEXT'YYYY-MM', so the month bucket is fine but range-filtering them is not — apply the Step 1start_datewindow as a client-side cutoff on the returned*_monthvalues when you build the final table. count_distinctrequires thefield(person_url) — that is what counts people, not stints. (countmay omit a field; the distinct/sum/avg/min/max functions all require one.)selectMUST be empty in aggregate mode — the result columns are exactly thegroup_bykeys followed by the aggregation names. Do not add aselectlist.- Confirm field names if unsure:
describe_onfire_schema(["experiences_pool"])before authoring. Never guess a field name.
Billing & the row budget (aggregate mode)
ask_onfire bills 1 credit per RETURNED row — and in aggregate mode a returned row is a group, i.e. one (month, title) bucket. So limit is a real budget on the number of groups.
- Always set an explicit
limit.200groups comfortably covers a year-plus of monthly buckets across the common roles at a mid-size company; lower it for a narrow title filter or a short window. - If the response comes back
needs_confirmationwithstage: "row_budget", nothing was billed and no rows came back — there are more groups than your budget. Lowerlimitto what the analysis actually needs and resubmit, or (only once the count is settled with the user) setconfirmed: true. Do not reflexively setconfirmed: truejust to push a large pull through. - A larger or older company spans more months × more roles; trade a higher
limitagainst the per-group cost and the window the user asked for.
Simple lookups (expressible directly)
These single-shot variants answer narrower questions without the flag step:
- Current people by title — drop the buckets/aggregations and pull rows:
filtersadd{dimension: "is_primary", op: "eq", value: true}(the entity'scurrentnamed filter,IS_PRIMARY = TRUE);select: ["person_url", "title_name", "title_role", "start_date"]. (Remember to keepallow_extended_pool: true.) - Headcount by role right now — aggregate:
filtersaddis_primary = true;group_by: ["title_role"];aggregations: [{name: "people", fn: "count_distinct", field: "person_url"}].
Step 4 — The "first-ever / now-gone" flags (client-side, bounded pull)
This is the only part ask_onfire cannot express. Whether a (month, title) bucket is genuinely new (the first-ever month anyone at the company held that title) or a title is fully gone (nobody currently holds it) requires a window / self-join over each title's full history — there is no QueryIR for that, and the experiences_pool caveats say so explicitly. So compute these two flags client-side over one small bounded pull, then join them onto the aggregate counts from Steps 2–3.
Pull just the columns the flags need, over the company's full history (no date cutoff — the "first-ever" flag depends on seeing all of it):
query_datasets: pull stints for the company from experiences_pool
entity: "experiences_pool", allow_extended_pool: true,
filters: [{dimension: "company_url", op: "eq", value: "<company_linkedin_url>"}],
select: ["title_role", "title_name", "start_date", "end_date"],
limit: <bounded> /* this is a row pull again — billed per stint, so keep it tight */
Treat start_date / end_date as 'YYYY-MM' month strings. For the chosen group_grain (default title_role), over ALL pulled rows:
first_ever_start[title]= the earlieststart_datemonth seen for that title at the company.currently_active[title]= count of stints for that title with an empty/NULLend_date(still ongoing).
Then derive the flags:
title_new_to_companyis TRUE for a(hire_month, title)bucket whenhire_month == first_ever_start[title]— the first-ever month anyone at the company held this title.title_gone_from_companyis TRUE for a title whencurrently_active[title] == 0— nobody at the company currently holds it.
This pull is billed per stint (it is a row pull, not aggregate mode), so keep
limittight — only the columns above, only this company. Iflimittruncates the company's stint history,first_ever_start/currently_activecan be off; widen it (mind the per-row cost) if the flags look suspect.
Step 5 — Merge and apply the window
Join the aggregate counts (Steps 2–3) and the flags (Step 4) on (month, title):
- Start from the union of
(hire_month, title)and(depart_month, title)keys. joiners=hiresfrom Step 2 (default 0);leavers=departuresfrom Step 3 (default 0).title_new_to_company/title_gone_from_companyfrom Step 4 (default FALSE).- Apply the
start_datewindow from Step 1: keep only months>= start_date. (The window is applied here, not in the query, because the month columns are TEXT.)
If show_all = false, keep only rows where title_new_to_company = TRUE OR title_gone_from_company = TRUE. If show_all = true, keep every bucket.
Order by month descending, then by joiners + leavers descending.
Two flag definitions worth knowing:
title_new_to_company— the first ever month anyone at the company held this title. Computed against the full Step 4 pull regardless of the date window.title_gone_from_company— current snapshot: nobody at the company currently holds this title (end_dateis set for all its stints). Not a point-in-time calculation for historical months.
Step 6 — Format the output
Summary line (always first)
📊 **[Company name]** · [N] months · 🟢 [X] new titles · 🔴 [Y] titles gone
If show_all=true, append: · [Z] total move events
Table (grouped by month, most recent first)
| Month | Title | Joiners | Leavers | Signal |
|---|---|---|---|---|
| 2026-02 | 🟢 distribution manager, latam | 1 | 0 | new to company |
| 2026-02 | 🔴 head of marketing | 0 | 1 | title now gone |
| 2026-02 | software engineer | 2 | 1 | (only if show_all=true) |
Signal rules:
title_new_to_company = TRUE→ 🟢 prefix + Signal = new to companytitle_gone_from_company = TRUE→ 🔴 prefix + Signal = title now gone- Both false → no emoji, blank Signal (only shown when
show_all=true)
No results
If the aggregate calls return 0 groups, say:
"No title movement found for
[url]in this window. The LinkedIn URL slug may not match — try a variant (e.g.linkedin.com/company/cloudforcevslinkedin.com/company/cloudforcecom)."
Edge cases
- Inferred URL: state it — "Using
linkedin.com/company/cloudforce— correct me if wrong." - Title noise: grouping by
title_role(default) collapses most spelling variants; if you group bytitle_name, variants (mid-market solution engineervsmid-market solutions engineer) appear as separate groups. Mention if prominent. - Future-dated end_date: occasional data artifact that can produce a departure bucket in the future. Note if it affects results.
- Large result sets (many
(month, title)groups withshow_all=true): suggest narrowing by title filter or date range — this also lets you lower theask_onfirelimitand reduce per-group cost.