Source Containers
The unit of conversation Modem processes: channels, tickets, issues, threads
A source container is one place where a conversation happens in one of your tools: a chat channel, a support ticket, a tracked issue, an email thread. Every message you send belongs to exactly one source container, and Modem processes messages source container by source container.
Source containers are how Modem aggregates across tools. A customer complaint in a support ticket, the issue your team filed for it, and the channel where engineers discussed the fix are three source containers from three sources. Modem reads each one on its own terms (a ticket has a status, a channel does not) and then connects what it finds into a single topic.
Why source containers matter
Before you send a single message, Modem needs to know what the source container is. The source container does four jobs:
| Job | What the source container provides |
|---|---|
| Triggers processing | Messages are queued against the source container they reference. Modem drains each source container serially, in message order, and nothing in a source container is processed until the source container exists. |
| Sets the scope | Messages in the same source container are read together; messages in different source containers are not. Modem deduplicates restated concerns within a source container, so how you scope source containers decides what gets read as one conversation. |
| Declares the kind | The kind tells Modem what the content means. A support thread is a customer stating a problem. An issue is your team’s restatement of one. Modem applies different extraction and topic rules to each. |
| Carries lifecycle | For kinds that have one, the source container’s state (open, in progress, done, …) and its transition history are evidence Modem uses to judge whether the work behind a topic is still open, in progress, completed, or dismissed. |
Identity
A source container is identified by three strings:
{
"source_name": "helphub",
"source_container": {
"kind": "support_thread",
"external_id": "48213"
}
}
| Field | Rule |
|---|---|
source_name |
The source’s slug, the same value you put on its messages. See Choosing a source_name. |
kind |
One of the source container kinds. Frozen once chosen. |
external_id |
A string you choose that is stable for the life of the source container. Usually the tool’s own ID for the channel, ticket, issue, or thread. |
Modem derives the source container’s internal ID from these three values plus your organization. The same triple always resolves to the same source container, which is what makes re-sending a source container snapshot safe and what lets a message find its source container.
Make external_id byte-for-byte identical everywhere you use it. "48213" and "ticket-48213" are two different source containers.
Source container kinds
kind is a policy key, not a label. It decides how Modem extracts content from the source container’s messages, how that content participates in topics, and which lens the source container appears under in the Sources view.
kind |
Use it for | One source container per | Lifecycle state | Available on the ingest API |
|---|---|---|---|---|
chat_channel |
Chat rooms, channels, alert and notification feeds, any ongoing stream | Channel or stream | None | Yes |
support_thread |
Help-desk tickets, customer chat conversations, two-way support email | Ticket or conversation | Yes | Yes |
issue |
Issue trackers, bug databases, project boards | Issue | Yes | Yes |
email_thread |
Mailbox threads | Thread root | None | Yes |
feedback_submission |
Public feedback boards, in-app feedback forms, feature request portals | Submission | Optional | Yes |
pull_request |
Code review: pull requests, merge requests | Pull request | Yes | Built-in integrations only |
transcript |
Recorded calls and meetings | Call | None | Built-in integrations only |
social_feed |
Public social media: a saved search, a subreddit, a hashtag | Saved search or feed | None | Built-in integrations only |
The examples below use fictitious products (ChatterBox, HelpHub, BugTrack, MailRoom, VoiceBox) so the shape is clear without implying that a specific real product must be modeled a particular way. If your tool looks like one of these, model it the same way.
chat_channel
An ongoing stream of messages with no beginning or end: a channel in your team chat, a shared room with a customer, an alerts feed a bot posts into. The conversation is the signal. People raise concerns directly, and Modem reads the messages as the concern itself rather than as a report about one.
A chat channel has no lifecycle. Send state: null. If your tool can archive channels, report that with archived_at.
Scope: one source container per channel. If your tool has workspaces or servers, put both IDs in external_id so channels with the same ID in different workspaces stay distinct.
{
"event_type": "source_container.created",
"created_at": "2026-03-09T15:30:01Z",
"client": { "name": "@acme/chatterbox-sync", "version": "1.0.0" },
"body": {
"data": {
"source_name": "chatterbox",
"source_container": {
"kind": "chat_channel",
"external_id": "ws_7f3a:ch_support_eng",
"parent": null,
"human_key": null,
"title": "#support-eng",
"description": "Escalations from the support team to engineering. One thread per customer issue.",
"state": null,
"state_changed_at": null,
"archived_at": null,
"author": null,
"external_url": "https://acme.chatterbox.example/ws_7f3a/ch_support_eng",
"external_created_at": "2024-06-11T09:00:00Z",
"external_updated_at": null,
"closed_at": null,
"metadata": { "workspace": "ws_7f3a", "visibility": "private" }
}
}
}
}
support_thread
A two-way conversation between one customer and your team about one problem: a help-desk ticket, an in-app chat conversation, a support email exchange. The customer states the concern in their own words, so Modem reads the thread as the concern itself.
A support thread has a lifecycle. The tool’s status maps onto Modem’s states, and the transition to done is strong evidence that the concern was resolved.
Scope: one source container per ticket or conversation.
{
"event_type": "source_container.created",
"created_at": "2026-03-09T15:30:01Z",
"client": { "name": "@acme/helphub-sync", "version": "1.0.0" },
"body": {
"data": {
"source_name": "helphub",
"source_container": {
"kind": "support_thread",
"external_id": "48213",
"parent": null,
"human_key": "#48213",
"title": "Can't log in after enabling SSO",
"description": null,
"state": "waiting",
"state_changed_at": "2026-03-09T15:22:40Z",
"archived_at": null,
"author": {
"source_author_id": "cust_91b2",
"display_name": "Priya Natarajan",
"email": "priya@customer.example"
},
"external_url": "https://acme.helphub.example/tickets/48213",
"external_created_at": "2026-03-09T14:02:11Z",
"external_updated_at": "2026-03-09T15:22:40Z",
"closed_at": null,
"metadata": { "priority": "high", "queue": "enterprise" }
},
"state_observation_source": "authoritative_snapshot",
"state_changed_at_kind": "provider_timestamp",
"external_state": { "status": "pending", "assignee": "agent_204" }
}
}
}
author is the person who opened the ticket. human_key is the short label people use to refer to it.
issue
A tracked work item with workflow state: a bug, a task, a card on a project board. Issues are usually your team’s restatement of a concern that was first raised somewhere else (a support thread, a chat message), so Modem treats them as tracking artifacts: an issue joins a topic readily, but it only seeds a new topic on its own when someone outside your organization took part in it. This keeps internal task churn from minting topics that no customer ever raised.
An issue has a lifecycle, and its lifecycle is the most informative of any kind. When the issue moves to done, Modem has direct evidence that the work behind the topic is complete.
Scope: one source container per issue.
{
"event_type": "source_container.created",
"created_at": "2026-03-10T10:15:00Z",
"client": { "name": "@acme/bugtrack-sync", "version": "1.0.0" },
"body": {
"data": {
"source_name": "bugtrack",
"source_container": {
"kind": "issue",
"external_id": "iss_01HXK4M9",
"parent": null,
"human_key": "ACME-1187",
"title": "SSO login loop when IdP rotates signing key",
"description": "Users on SSO get bounced back to the login page after the IdP rotates its signing key. Reported via helphub #48213.",
"state": "in_progress",
"state_changed_at": "2026-03-10T10:14:02Z",
"archived_at": null,
"author": {
"source_author_id": "usr_3c1e",
"display_name": "Dana Whitfield",
"email": "dana@acme.example"
},
"external_url": "https://acme.bugtrack.example/ACME-1187",
"external_created_at": "2026-03-09T16:40:00Z",
"external_updated_at": "2026-03-10T10:14:02Z",
"closed_at": null,
"metadata": { "labels": ["auth", "sso"], "project": "platform" }
},
"state_observation_source": "explicit_lifecycle",
"state_changed_at_kind": "provider_timestamp",
"external_state": { "status": "In Progress", "assignee": "usr_8a90" }
}
}
}
Use external_id for the tool’s immutable ID and human_key for the human-readable key. Keys like ACME-1187 can change when an issue moves between projects; internal IDs do not.
email_thread
A thread in a mailbox, keyed on the root message. Replies often quote earlier messages verbatim, and Modem accounts for that when it reads the thread. Like a chat channel, a mail thread has no lifecycle: send state: null.
Scope: one source container per thread. Use the root message’s Message-ID header (or your mail provider’s thread ID) as external_id, and send the same source container snapshot from every message in the thread. Modem keeps whichever snapshot it observed most recently, so a snapshot derived from a reply is fine as long as the fields you fill are ones every message in the thread agrees on (the subject with Re:/Fwd: stripped, for example).
{
"event_type": "source_container.created",
"created_at": "2026-03-11T08:05:00Z",
"client": { "name": "@acme/mailroom-sync", "version": "1.0.0" },
"body": {
"data": {
"source_name": "mailroom",
"source_container": {
"kind": "email_thread",
"external_id": "<CAF3x9k2@mail.customer.example>",
"parent": null,
"human_key": null,
"title": "Renewal pricing for the Enterprise plan",
"description": null,
"state": null,
"state_changed_at": null,
"author": null,
"external_url": null,
"external_created_at": null,
"external_updated_at": "2026-03-11T08:04:31Z",
"closed_at": null,
"metadata": { "mailbox": "sales@acme.example" }
}
}
}
}
author and external_created_at belong to the thread’s root message. If the message you are looking at is a reply, you do not know them; send null rather than the reply’s values.
feedback_submission
Something a customer wrote unprompted that stands on its own: a post on a public feedback board, a feature request submitted through a form, an in-app “send feedback” entry. Nobody is obliged to reply, which is what distinguishes it from a support thread. Where the tool supports voting or comments, that activity reads as demand for the request rather than as a back-and-forth about it.
Lifecycle is optional. Many feedback boards have a status (under review, planned, shipped); if yours does, map it. If it does not, send state: null.
Scope: one source container per submission.
{
"event_type": "source_container.created",
"created_at": "2026-03-12T13:00:00Z",
"client": { "name": "@acme/voicebox-sync", "version": "1.0.0" },
"body": {
"data": {
"source_name": "voicebox",
"source_container": {
"kind": "feedback_submission",
"external_id": "post_5d2a7",
"parent": null,
"human_key": null,
"title": "Let admins force re-authentication after an SSO config change",
"description": null,
"state": "open",
"state_changed_at": null,
"archived_at": null,
"author": {
"source_author_id": "u_44f1",
"display_name": "Marcus Oyelaran"
},
"external_url": "https://acme.voicebox.example/p/post_5d2a7",
"external_created_at": "2026-03-12T12:41:00Z",
"external_updated_at": "2026-03-12T12:41:00Z",
"closed_at": null,
"metadata": { "votes": 37, "board": "authentication" }
},
"state_observation_source": "authoritative_snapshot"
}
}
}
The submission’s own text is a message. Send the post body as the first message.created in the source container, then each comment as a further message.
Built-in-only kinds
Three kinds are produced by Modem’s built-in integrations and are not yet accepted through the ingest API. They are listed so the full model is visible.
kind |
What it is |
|---|---|
pull_request |
An implementation artifact addressing some concern. It reports on work rather than raising it, so a pull request can join a topic but cannot anchor one alone. Lifecycle: in_progress while open, done when merged, canceled when closed without merging. |
transcript |
A recorded call or meeting. A live spoken conversation, read as the concern itself. Born complete, no lifecycle. |
social_feed |
A public social feed such as a saved search. There is one source container per query, not per conversation, because duplicates on social media cross conversation boundaries. No lifecycle. |
Lifecycle state
Kinds that track work (support_thread, issue, feedback_submission, pull_request) carry a state. Your tool has its own vocabulary; Modem normalizes it to five values.
state |
Meaning |
|---|---|
open |
Exists, nobody has started on it |
in_progress |
Someone on your side is actively working on it or has responded |
waiting |
Blocked on someone else, usually the customer or reporter |
done |
Resolved or completed |
canceled |
Closed without being resolved: won’t fix, duplicate, spam, merged into another item |
null |
This source container has no lifecycle (chat channels, email threads, transcripts, feeds) |
Mapping your tool’s statuses
Map each native status to the closest Modem state. The mapping does not need to be lossless; put the native status in external_state if you want it preserved.
| Tool | Native status | Modem state |
|---|---|---|
| Help desk | New, Unassigned | open |
| Open (assigned), Agent replied | in_progress |
|
| Pending, On hold, Awaiting customer | waiting |
|
| Solved, Resolved, Closed | done |
|
| Merged, Spam, Deleted | canceled |
|
| Issue tracker | Backlog, Triage, Todo | open |
| In progress, In review, QA | in_progress |
|
| Blocked, Needs info | waiting |
|
| Done, Shipped, Released | done |
|
| Canceled, Won’t fix, Duplicate | canceled |
|
| Feedback board | Open, Under review, Gathering feedback | open |
| Planned, In progress, Building | in_progress |
|
| Shipped, Complete | done |
|
| Closed, Declined, Not planned | canceled |
A few judgment calls:
- A ticket the customer has replied to after an agent response usually goes back to
openorin_progress, depending on whether the agent is still assigned. - Reopened items go back to whatever state fits; Modem records every transition, so a
done→open→donehistory is fine. - Closed vs. resolved. If your tool distinguishes them and both mean “the work is finished”, map both to
done.canceledis for items that ended without resolution. - If you cannot tell, prefer
openover inventing precision. A wrongdoneis worse than a vagueopen.
Why state matters
Source container state is the most direct lifecycle evidence Modem has. When Modem assesses whether a topic is open, in progress, completed, or dismissed, it reads the current state and transition history of every source container that contributes to the topic. A ticket moving to done, or an issue moving to canceled, changes that assessment. Conversation content alone (“we shipped it yesterday”) is also evidence, but a status change is unambiguous.
This is why state is unimportant for a chat channel and very important for a ticket. Nobody “closes” a channel when a problem is fixed, so a channel’s messages carry the resolution signal in their content. A ticket’s status is the resolution signal, and if you send state: null for every ticket, Modem loses it.
State fields
When state is non-null, describe how you learned it.
| Field | Required | Notes |
|---|---|---|
state_observation_source |
Yes when state is non-null |
How you learned the state. Must be absent when state is null. See the values below |
state_changed_at |
Nullable | When the source container entered its current state, according to your tool. null if the tool does not say |
state_changed_at_kind |
Optional | provider_timestamp when state_changed_at came from the tool; lifecycle_observation when it is your own observation time standing in for one. Defaults to provider_timestamp |
closed_at |
Nullable | When the source container reached done or canceled. null while it is open |
external_state |
Optional | A small JSON object with the tool’s native status fields, preserved alongside the transition. Defaults to { "state": <state> } |
archived_at |
Optional, nullable | Archive is separate from lifecycle. Omit the field if your tool has no archive concept; send null for “not archived”; send a timestamp for “archived”. An omitted field never overwrites a stored value |
state_observation_source values, from most to least authoritative:
| Value | Use it when |
|---|---|
explicit_lifecycle |
A lifecycle event told you the state changed: a ticket.solved webhook, an issue.closed notification |
authoritative_snapshot |
You read the current record from the tool’s API, or a webhook delivered the full current object |
api_backfill |
You are importing historical records in bulk |
structured_fallback |
You inferred the state from structured fields that are not the status itself (a resolved_at timestamp with no status field, for instance) |
message_fallback |
You inferred the state from message content |
When two observations of the same source container arrive with the same created_at, the state from the more authoritative source wins. The other snapshot fields take whichever write arrives later. Otherwise the most recently observed snapshot wins. See Idempotency.
Events
Three event types manage source containers. All use the standard envelope.
source_container.created
Stands up a source container. body.data carries a complete snapshot; every field is present, with null for unknown values.
| Field | Type | Notes |
|---|---|---|
source_name |
string | The source slug. Must match the source_name on the source container’s messages |
source_container.kind |
string | One of the kinds available on the ingest API |
source_container.external_id |
string | The source container’s stable identity. See Identity |
source_container.parent |
{kind, external_id} or null |
Reserved for nesting (a thread inside a channel). Send null |
source_container.human_key |
string or null |
The short label people use: #48213, ACME-1187 |
source_container.title |
string or null |
Rendered as extraction context. Populate it |
source_container.description |
string or null |
Rendered as extraction context. Populate it for channels and feeds, whose messages rarely explain what the source container is for |
source_container.state |
enum or null |
See Lifecycle state |
source_container.state_changed_at |
ISO 8601 or null |
|
source_container.archived_at |
ISO 8601 or null, optional |
See State fields |
source_container.author |
author or null |
Who opened the source container. Same shape as a message author. Send null if unknown; within the object, omit unknown fields rather than sending null |
source_container.external_url |
URL or null |
Deep link into your tool |
source_container.external_created_at |
ISO 8601 or null |
When the source container was created in your tool |
source_container.external_updated_at |
ISO 8601 or null |
When it last changed in your tool |
source_container.closed_at |
ISO 8601 or null |
|
source_container.metadata |
object | Free-form JSON. Send {} if you have nothing |
state_observation_source |
enum | Required when state is non-null; forbidden when state is null |
state_changed_at_kind |
enum, optional | |
external_state |
object, optional | |
provider_payload |
object, optional | The raw record from your tool, if you want Modem to retain it. Scrubbed for PII before storage |
provider_payload_source |
string, optional | Where provider_payload came from (webhook, rest_api, …) |
source_container.updated
Identical body to source_container.created. Send it whenever the source container changes in your tool: a status transition, a retitle, an archive. Always send the full snapshot, not a diff.
The two event types differ in exactly one way: a source_container.created observed after a deletion revives the source container, and a source_container.updated never does (see below). Otherwise they are interchangeable, and if you are not sure whether Modem has seen a source container, sending source_container.created again is safe.
source_container.deleted
Tombstones a source container. Once deleted, the source container’s messages are no longer processed.
{
"event_type": "source_container.deleted",
"created_at": "2026-04-01T09:00:00Z",
"client": { "name": "@acme/helphub-sync", "version": "1.0.0" },
"body": {
"data": {
"source_name": "helphub",
"source_container": {
"kind": "support_thread",
"external_id": "48213"
},
"deleted_at": "2026-04-01T08:59:12Z"
}
}
}
deleted_at is when the source container was removed in your tool. Send a deletion only for an actual removal. A ticket that was closed or merged is a state change (done or canceled), not a deletion.
A deletion is a tombstone, not a purge. The row and its messages remain, and a source_container.created event observed after the deletion revives it: deleted_at is cleared, the new snapshot is stored, and processing resumes. source_container.updated does not revive; it updates the snapshot but leaves the source container deleted.
If the first event Modem ever sees for an identity is a deletion, it records a bare tombstone (identity and deleted_at, nothing else). Messages referencing it wait as they would for any missing source container, and a later source_container.created fills it in and starts processing.
Idempotency
Source container events are upserts keyed on the source container’s identity. Re-sending a snapshot is always safe. Modem keeps the snapshot with the most recent created_at, so:
- A retry of the same event is a no-op.
- Two events with different
created_atresolve to the later one, regardless of arrival order. An out-of-order delivery of an older snapshot cannot overwrite a newer one. - Two events with the same
created_atresolvestatebystate_observation_sourcerank, then bysource_nonce(or Modem’s event ID when you sent no nonce). The other snapshot fields take whichever write arrives later.
This is why created_at must be your observation time and never later than the moment you looked. A bulk import stamped with the import time would outrank every live observation that came before it.
State transitions are additionally recorded as history. A source container that moved open → in_progress → done keeps all three facts even though only done is current, and that history is part of the lifecycle evidence.
Ordering and failure modes
The source container gates the start of processing, not the acceptance of messages. A message that references a source container Modem has not seen yet is stored with its reference intact and waits. Once the source container’s source_container.created event lands, the queued messages are processed in order. Sending the source container first is the natural order, but a message arriving before it is not an error.
Other rules:
- The reference must match exactly.
source_name,kind, andexternal_idon a message must equal the source container’s byte for byte. A mismatch is a reference to a different (probably nonexistent) source container, so the message waits for a source container that never arrives. - The reference will soon be required. Messages without a
source_containerreference are stored but not processed today. Once the requirement is enforced they will be rejected with a400. See The source container reference. - A message’s source container is fixed once it is set. Sending the same message again with a different reference does not move it.
kindis validated. Akindoutside the available set returns400.- One source container event per source container, plus one per lifecycle change. Do not send a source container snapshot alongside every message. Re-sending is safe and is not metered today, but it is wasted work.
Related
Messages
The message payload and how a message references its source container.
Ingest API overview
Authentication, the event envelope, and the end-to-end protocol.
Topics
What Modem builds from the source containers and messages you send.
Integrations
Built-in integrations, which model their sources the same way.