agentleFS
engine9-aiengine9-ai/skills/e9-timeline/SKILL.md

e9-timeline

Skill1 starsChanged 48 days agoApache-2.0View on GitHub

Understand and troubleshoot the engine9 person activity timeline, including entry types, timeline and input tables, detail and summary views, plugin loading, queries, segments, and missing-entry diagnosis. Use when working with timeline, entry_type_id, EMAIL_OPEN, EMAIL_CLICK, EMAIL_SEND, person_entry_summary, timeline detail, person activity, or absent opens, clicks, sends, transactions, and other entries; timeline rows are entries, never events.

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 0 are distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every timeline row has:

    line 60
  2. Very specific:

    Prefer a *_summary view when you want plugin name, input name, source code string, and entry_type (the name, not the integer).

    line 75
  3. Very specific:

    Prefer MCP timelinePersonLegacy over this SQL when inspecting one person.

    line 226
  4. Very specific:

    Gap: no person → identity / load of the people file, not timeline.

    line 260
  5. Very specific:

    Every timeline row points at an input (the message, form, or extract).

    line 264
  6. Very specific:

    Segment empty: check universe (message publish_date / channel) and search window.

    line 321

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

engine9 timeline

The timeline is each person’s activity log, with one entry for one fact about one person at one time: an email send, open, or click; an SMS; a transaction; a signup; a form submission; or a segment change. Plugins load activity recorded by remote systems after identity is resolved to person_id; engine9 does not invent timeline rows from aggregate reports. Use this skill to understand storage, query activity, design engagement segments, or diagnose a missing entry.

Quick reference

NeedStart here
Inspect current entriestimeline
MCP current person timelinetimelinePerson inspect
MCP legacy timeline_v3*timelinePersonLegacy (separate tool)
Resolve the producing streamtimeline.input_id → input.id
Read entry names and plugin contextA current plugin *_summary view
Find first, last, or count by typeperson_entry_summary
Diagnose one missing entryTroubleshooting workflow A–F

Rules

Rule: Say entry, never event. Vendor systems may call the same facts events, but once they land in engine9 they are entries.

Rule: Say transaction, never donation. Revenue questions use transaction tables, not timeline TRANSACTION_* entries.

Rule: A click is not automatically an open; openers and clickers are independent.

Rule: For a missing entry, walk troubleshooting steps A–F in order, stop at the first gap, and inspect product data before application code.

Rule: Current timeline and legacy timeline_v3* are separate MCP calls. Never ask timelinePerson inspect for legacy tables.

Concepts

Warehouse tables

Table / viewRole
timelineCanonical entries. One row per entry: id, ts, person_id, entry_type_id, input_id
inputThe inbound stream that produced the entries (a message, form, ad, SQL extract, …). Owns plugin_id, remote_input_name, min_timeline_ts / max_timeline_ts
pluginWhich integration owns the input
source_code_dictionaryOptional source code on the entry (timeline.source_code_id)
plugin detail tablesExtra columns (URL, amount, user agent, …) keyed by the same entry id
<detail>_summaryView: timeline + input + plugin + source code + detail extras, with a string entry_type
person_entry_summaryPer (person_id, entry_type_id): first_ts, last_ts, entry_count
timeline_v3Legacy entries (old identity, person_id_int). Not current timeline.
timeline_v3_summaryLegacy inspect view. Type name is entry_type_label, not entry_type.

timeline is the hub. Detail tables and summaries hang off timeline.id. Segments and the person Timeline tab read timeline, often joined to input.

Current vs legacy type names: current timeline stores entry_type_id (integer); current plugin *_summary views add string entry_type. Legacy timeline_v3_summary uses entry_type_label for that string — do not query entry_type on it.

Detail table names vary by plugin (…_timeline_detail, timeline_detail_email_open, …). DESCRIBE / list tables; do not guess.

File format

Anatomy of an entry

Every timeline row has:

FieldMeaning
idStable UUID for this entry. Reloading the same entry upserts (no duplicate).
tsWhen it happened (not when engine9 loaded it).
person_idCanonical person. Entries never invent identity.
entry_type_idWhat kind of entry (integer; names below).
input_idWhich input (message / form / extract) it came from. Required.
source_code_idOptional. Last-click / origin code on this entry.
email_domainOptional. Lowercased domain, often derived from email. Used for domain rollups.
created_atWhen engine9 stored the row.

The console Person → Timeline tab is timeline filtered to that person_id.

Prefer a *_summary view when you want plugin name, input name, source code string, and entry_type (the name, not the integer). Fall back to joining timeline → input → plugin yourself.

MCP: current timeline vs legacy timeline

These are two tools. The UI should call them independently so legacy can be dropped (or loaded only when the Legacy tab is opened) without slowing current inspect.

NeedMCP toolWorker
Current timeline / model_* for a person or source filtertimelinePerson command: inspectModelWorker.inspectPerson
Legacy timeline_v3* / person_model_source_codetimelinePersonLegacyModelWorker.inspectPersonLegacy

Rule: timelinePerson inspect never reads timeline_v3*. Do not pass legacy: true on inspect expecting those tables. Pivot stats and legacy model search are e9-model/legacy.md, and only when the user explicitly asked for legacy.

Rule: person.id is not person_id_int. Legacy person detail hashes email onto person_metadata. Current person_ids on timelinePersonLegacy are only an email lookup on person_email.

Rule: inspect requires emails, person_ids, source_codes, or segment_id. Unfiltered account-wide inspect is rejected (full-table identity COUNTs were too expensive).

Current inspect (default person Timeline / Models view):

{ "account_id": "<account_id>", "emails": "user@example.com" }
{ "account_id": "<account_id>", "person_ids": 1517 }
{ "account_id": "<account_id>", "source_codes": "EM_%" }
{ "account_id": "<account_id>", "person_ids": 1517, "source_codes": "EM_%" }
{ "account_id": "<account_id>", "segment_id": "<segment_uuid>" }

Optional start / exclusive end (YYYY-MM-DD or relativeDate) scope timeline.ts. Returns section: "current" tables: timeline_counts, timeline, models. Timeline type is entry_type / entry_type_id. effective_date is timeline.ts.

Legacy inspect (optional second call; unregister the tool to slice it away):

{ "account_id": "<account_id>" }
{ "account_id": "<account_id>", "emails": "user@example.com" }
{ "account_id": "<account_id>", "person_ids": 1517 }

Returns section: "legacy" tables: timeline_v3 min/max/count, transaction_model_source_code, and person-level timeline_v3_summary / person_model_source_code when an email is present. person_model_source_code_totals only when emails, person_ids, or source_codes is set (skipped account-wide — full-table COUNT is too expensive). Type column is entry_type_label. Missing tables are skipped. Future deployments will drop this tool.

UI merge: keep current and legacy tables[] / sql[] side by side (section already distinguishes them). Do not combine the warehouse queries into one request. Load current first; call timelinePersonLegacy only when the Legacy surface is needed.

Entry types

timeline stores entry_type_id (integer). Summary views add the string entry_type. Filter SQL on the integer; talk to people in the names.

GroupNameIdTypical meaning
OriginCRM_ORIGIN1Person’s origin in the CRM
OriginACQUISITION2Acquisition
SignupSIGNUP / SIGNUP_INITIAL / SIGNUP_SUBSEQUENT3 / 4 / 5List / form signup
SignupUNSUBSCRIBE6Channel-agnostic unsubscribe
SignupDATA_APPEND7Appended attributes (often source-code related)
MoneyTRANSACTION10Generic payment; prefer a specific type
MoneyTRANSACTION_ONE_TIME11One-time payment
MoneyTRANSACTION_INITIAL / TRANSACTION_SUBSEQUENT / TRANSACTION_RECURRING12 / 13 / 14Recurring series
MoneyTRANSACTION_REFUND15Refund
SegmentSEGMENT_PERSON_ADD / REMOVE / IN_SEGMENT16 / 17 / 18Audience membership
MessageMESSAGE_CONVERSION / _ADVOCACY / _TRANSACTION20 / 21 / 22Conversion credited to a message
MessageMESSAGE_DELIVERY_FAILURE_SHOULD_RETRY / _SHOULD_NOT_RETRY25 / 26Delivery failure
SMSSMS_SEND / SMS_DELIVERED / SMS_CLICK30 / 31 / 33SMS lifecycle
SMSSMS_UNSUBSCRIBE / SMS_BOUNCE / SMS_SPAM / SMS_REPLY34 / 37 / 38 / 39SMS negative / reply
EmailEMAIL_SEND / EMAIL_DELIVERED40 / 41Send / delivery
EmailEMAIL_OPEN / EMAIL_CLICK42 / 43Engagement
EmailEMAIL_UNSUBSCRIBE / EMAIL_SOFT_BOUNCE / EMAIL_HARD_BOUNCE / EMAIL_BOUNCE / EMAIL_SPAM / EMAIL_REPLY44 / 45 / 46 / 47 / 48 / 49Email negative / reply
PhonePHONE_CALL_ATTEMPT / SUCCESS / FAIL50 / 51 / 52Calls
FormsFORM_SUBMIT / FORM_PETITION / FORM_ADVOCACY / FORM_SURVEY60 / 61 / 66 / 67Actions
OtherFILE_IMPORT, EXPORT*70, 80–82Loads and pushes
OtherINFERRED_*91–93Modeled, not observed

SOURCE_CODE_OVERRIDE (0) is a dictionary override marker, not a person action.

Workflow

Load entries onto the timeline

  1. A plugin extracts activity from a remote system (ESP activity, CRM actions, payment rows, form posts, …).
  2. Each extract is an input — the generic wrapper for one inbound stream (a message, form, ad, SQL table, or other named extract; input.remote_input_name).
  3. engine9 matches each row to a person (email / phone / remote_person_id). Unknown people are created; known keys reuse the existing person_id.
  4. Each row gets a stable id. Same person + type + time + plugin (or a vendor remote_entry_id / remote_entry_uuid) → same UUID → upsert.
  5. Core fields go to timeline. Extra fields go to the plugin detail table. A summary view is refreshed so reports can join names without repeating that SQL.

Until step 3 succeeds, the row is not on timeline. A file that only has an email is not a timeline entry yet.

Choose the correct data model

QuestionWhere to look
Who is this person? Duplicate people?e9-person-id — not timeline.id
Why is revenue on the wrong email?e9-source-code — transaction_summary, not timeline engagement
Did this person open / click / get sent a message?timeline (EMAIL_*, SMS_*) — inventory Timeline → Messages (per-person), not aggregate Messages
How did messages perform overall (sent / opens / clicks)?global_message_summary — inventory Messages (aggregate). e9-global-message
Did this person transact? Amount, recurring, refund?transaction / transaction_summary (also mirrored as TRANSACTION_* timeline rows)
Is this person in an engagement audience?Segment build: universe (which inputs) + search (which entry_type_id / window)

Email engagement segments (30/60/90-day openers and clickers) read timeline joined to input. The universe chooses which sends can contribute (typically email messages published in the last 90 days). The search chooses people with EMAIL_OPEN or EMAIL_CLICK in the rolling window. An open on a message outside that universe does not count, even if the open itself is recent.

Examples

Querying

DESCRIBE first if column names differ. Filter to one person (and one entry type when known). LIMIT.

-- Person → entries (use the integer type, or join a summary for the name)
SELECT id, ts, entry_type_id, input_id, source_code_id
FROM timeline
WHERE person_id = 12345
ORDER BY ts DESC
LIMIT 50;

-- Opens on that person
SELECT id, ts, input_id
FROM timeline
WHERE person_id = 12345
  AND entry_type_id = 42  -- EMAIL_OPEN
ORDER BY ts DESC
LIMIT 20;

-- Entry → which message / plugin
SELECT
  t.id,
  t.ts,
  t.entry_type_id,
  i.remote_input_name,
  i.input_type,
  p.name AS plugin_name,
  p.path AS plugin_path
FROM timeline t
JOIN input i ON i.id = t.input_id
JOIN plugin p ON p.id = i.plugin_id
WHERE t.person_id = 12345
ORDER BY t.ts DESC
LIMIT 50;

-- Ever / last activity of a type (if the rollup exists)
SELECT person_id, entry_type_id, first_ts, last_ts, entry_count
FROM person_entry_summary
WHERE person_id = 12345
LIMIT 50;
-- Legacy person inspect: timeline_v3_summary (not timeline, not base timeline_v3).
-- The type string is entry_type_label — current tables use entry_type / entry_type_id.
SELECT effective_date, person_id_int, entry_type_label, source_code
FROM timeline_v3_summary
WHERE person_id_int = 1517
ORDER BY effective_date DESC
LIMIT 100;

Prefer MCP timelinePersonLegacy over this SQL when inspecting one person. Prefer MCP timelinePerson inspect for current timeline.

Resolve email → person_id via person_email, then query timeline.

Rule: Do not treat timeline.id as a person key. For legacy inspection, pair via email, person_metadata, or person_id_int; never join person_id_int to current person.id.

Troubleshooting

Missing-entry workflow A–F

Use this when diagnosing a Timeline issue (person Timeline tab empty/wrong, missing open/click/send, engagement segment empty, “we sent this but engine9 has no entry”). Find the person and input from the product first; SQL confirms one named entry. Do not inspect application code.

Walk A → F in order. Stop at the first gap. Pick one person and one expected entry (type + message/input if known).

Timeline pipeline:
- [ ] A: Person exists (person_id from email / phone / remote id)
- [ ] B: Input exists for that message / form / stream
- [ ] C: timeline row for that person_id
- [ ] D: entry_type_id is the expected type
- [ ] E: input_id joins to the expected input / plugin
- [ ] F: Detail, summary, or segment universe (complaint is extras or an audience, not the raw entry)

A) Person exists

The entry cannot land without a person_id.

SELECT person_id, email FROM person_email
WHERE LOWER(email) = 'alice@example.com'
LIMIT 20;

Gap: no person → identity / load of the people file, not timeline. Switch to e9-person-id. Gap: several person_ids for one email → you may be looking at the wrong person; the entry can sit on the other id.

B) Input exists

Every timeline row points at an input (the message, form, or extract).

SELECT i.id, i.remote_input_name, i.input_type, i.min_timeline_ts, i.max_timeline_ts, p.name, p.path
FROM input i
JOIN plugin p ON p.id = i.plugin_id
WHERE i.remote_input_name LIKE '%Appeal%'
LIMIT 20;

Pass: the expected message/form is an input row, with a plugin you recognize. Gap: no input → that stream was never loaded (remote extract or plugin install). Not a timeline query bug.

C) A timeline row for that person

SELECT id, ts, entry_type_id, input_id
FROM timeline
WHERE person_id = ?
ORDER BY ts DESC
LIMIT 50;

Pass: any rows in the window you care about. Gap: person and input exist but this person has no rows → the vendor file had no corresponding activity for them, identity did not match this person, or the load did not run. Check input.min_timeline_ts / max_timeline_ts and input.records. Remote vs in-account: if the ESP/CRM also lacks the activity, it is remote.

D) Correct entry type

SELECT entry_type_id, COUNT(*) AS n
FROM timeline
WHERE person_id = ?
GROUP BY entry_type_id
LIMIT 50;

Pass: the expected id is present (42 open, 43 click, 40 send, …). Gap: sends exist but no opens → often real (they were sent, they did not open), or the ESP engagement file was not loaded. Gap: you filtered the UI/segment on EMAIL_OPEN while the row is EMAIL_CLICK (or the reverse). They are not interchangeable.

E) Right input / plugin

SELECT t.id, t.ts, t.entry_type_id, i.remote_input_name, p.name AS plugin_name
FROM timeline t
JOIN input i ON i.id = t.input_id
JOIN plugin p ON p.id = i.plugin_id
WHERE t.person_id = ?
  AND t.entry_type_id = 42
ORDER BY t.ts DESC
LIMIT 20;

Pass: the row is attached to the message/plugin you expected. Gap: entries exist but on a different input (wrong message, another ESP, a test plugin). The person Timeline tab shows all inputs; a segment universe may hide this one.

F) Detail, summary, or segment — not the raw entry

The timeline row is present and correctly typed/joined, but the complaint is a blank extra field, a summary view, or an audience.

  • Detail missing: the core entry loaded; the plugin detail table did not. The Timeline tab can still show the entry.
  • Summary stale: *_summary / person_entry_summary disagree with timeline → the summary job has not run. Trust timeline.
  • Segment empty: check universe (message publish_date / channel) and search window. A recent open on a message published more than 90 days ago does not qualify for the shipped email-opener segments.
  • UI disagrees after F matches: report/UI issue, not this pipeline.

Record the first failing step (other accounts; ESP/CRM/payment remotes).

Related documentation

  • Load path (ID files → tables): loading.md
  • File shapes for plugins: inputs/timeline
  • Input grain (one form / blast / ad / table / extract): e9-input
  • Person identity: e9-person-id
  • Source codes / attribution (transaction ↔ message): e9-source-code
  • Models (timeline long-term value): e9-model
  • Aggregate message performance: e9-global-message

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.