Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 open or in_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 → done history is fine.
  • Closed vs. resolved. If your tool distinguishes them and both mean “the work is finished”, map both to done. canceled is for items that ended without resolution.
  • If you cannot tell, prefer open over inventing precision. A wrong done is worse than a vague open.

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_at resolve 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_at resolve state by state_observation_source rank, then by source_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, and external_id on 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_container reference are stored but not processed today. Once the requirement is enforced they will be rejected with a 400. 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.
  • kind is validated. A kind outside the available set returns 400.
  • 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.

Last updated on

Was this page helpful?