Inbox¶
The Inbox exists for one situation: you find a paper on your phone, where ALMa isn't installed. You send it to yourself, and it is waiting in ALMa when you sit down — already in the corpus, already enriched, needing only a decision.
It is a buffer, not a storage place. Papers are not supposed to accumulate here. Everything that lands is a full corpus citizen from the moment it arrives; the Inbox only marks that you haven't decided about it yet.
Where it sits in the lifecycle¶
The Inbox is a value on the membership axis (see
Paper lifecycle), between tracked and library:
tracked ──capture──▶ inbox ──┬─ Save / Like / Love ──▶ library
├─ Dislike ─────────────▶ rating 1
└─ X (defer) ──────────▶ tracked
The reading axis is untouched by all of this. Capturing a paper says nothing about whether you intend to read it.
What "in the corpus in every sense" means¶
An Inbox paper is an ordinary papers row. Concretely:
- It gets enriched.
corpus_rehydrate, the S2 vector fetch and the local SPECTER2 fill don't filter on status, so abstract recovery and embeddings happen while it sits there. By the time you triage it, it's complete. - It appears on the semantic map and in search, and participates in deduplication — so if the same paper later arrives via Feed, it is recognised as the one you already have.
- It does not affect Discovery. Every preference query is scoped to
status='library'(paper_signal.py,discovery/engine.py,discovery/scoring.py). A paper you capture at 8am and X out at noon leaves no trace on your recommendations. That is what makes a two-second phone flick a safe gesture.
The X button¶
The X on an Inbox card means "I looked at this and have no action for it." It
returns the paper to tracked and writes nothing else — no rating, no
feedback event.
It is deliberately not the same verb as Discovery's dismiss. Under the amended D6 (2026-07-26):
| Verb | Axis | Scope | Writes |
|---|---|---|---|
save / like / love / dislike |
valence | global — one opinion per paper | rating + feedback event |
dismiss |
resolution | the surface that raised it | that surface's row only |
hide |
visibility | global | status='dismissed' |
remove |
membership | global | status='removed' (a negative, per D3) |
So "I've dealt with this here" and "this is bad" are different statements.
Negative opinion is dislike; bad and gone is dislike plus dismiss — two
verbs you compose deliberately.
Channels¶
The Inbox is channel-agnostic. Slack is the first delivery channel, but the
capture pipeline knows nothing about it. Any transport becomes a valid source by
honouring the schema in alma.application.inbox_schema.
┌── channel adapter ──┐ ┌──── application/inbound_capture ─────┐
│ Slack fetch() │ │ extract → resolve → land → promote │
│ Email acknowledge│──▶│ │
│ … │ │ (knows no transport) │
└─────────────────────┘ └───────────────────────────────────────┘
The contract¶
A channel adapter implements InboundChannel — three methods, no database
access, no knowledge of papers:
| Member | Purpose |
|---|---|
name |
Channel id ('slack'). Also the added_from stamped on brand-new papers. |
is_configured() |
Is this channel set up enough to poll? An unconfigured channel is simply off, never an error. |
fetch(since_cursor) |
New messages, oldest first. Over-fetching is safe; under-fetching loses captures. |
acknowledge(message, result) |
Tell the user, in the channel, what happened. Best-effort. |
and hands in InboundMessage:
| Field | Meaning |
|---|---|
channel |
Which adapter produced this. |
external_id |
Channel-unique, stable id. Slack's message ts; email's Message-ID. Half the idempotency key. |
received_at |
When the channel says it arrived, ISO-8601. Never fabricated. |
text |
Raw body, verbatim. |
urls |
Links the channel already parsed. Slack wraps links as <url\|label>, so its own parse beats a regex over display text. |
metadata |
Channel-specific extras, opaque to the pipeline (Slack's channel_id, thread_ts). |
and gets back CaptureResult:
| Outcome | Meaning |
|---|---|
resolved |
Identified; in the corpus at status='inbox'. |
duplicate |
Already in your Library. Left completely alone — a save is never demoted — and not parked, because it needs no triage. |
unresolved |
No usable identifier, or nothing upstream recognised it. Recorded, never dropped. |
error |
The pipeline failed (upstream down). Distinct from unresolved because this one is worth retrying. |
Idempotency¶
Channel delivery is at-least-once: polling twice, or crashing mid-batch,
re-delivers the same message. Every capture therefore starts with a lookup on
(channel, external_id), which is UNIQUE on inbox_messages. A redelivered
message replays its recorded outcome instead of producing a second copy of the
paper.
The poll cursor is MAX(external_id) per channel, derived from that same table —
so the cursor can never claim progress the database doesn't actually hold.
What inbox_messages stores¶
Messages, never papers. A resolved capture is an ordinary papers row; this
table records that a message arrived and what became of it. It exists for four
jobs nothing else can do: idempotency, a durable home for messages that resolved
to no paper, the cursor, and provenance.
Provenance is what lets a triage tile on Home say "Slack · captured 2h ago",
and it is also the Inbox's sort key. Ordering captures by papers.added_at
would answer the wrong question — that is when a paper entered your corpus —
so a paper your Feed collected two years ago, re-sent from your phone this
morning, would sink to the bottom of the queue it had just joined. Home reads
the newest message per paper: re-sending a link you already sent is the same
capture, not two.
Slack¶
Why polling, not the Events API¶
Slack's normal design HTTP-POSTs to your server, which needs a public HTTPS
endpoint. ALMa binds 127.0.0.1 with no auth by default, so there is nothing
for Slack to reach — and exposing it would mean putting an unauthenticated app
on the public internet. Polling is outbound-only: no open port, no tunnel, no
public surface.
Socket Mode (an outbound websocket) would also work and would be instant. It
needs a second app-level token and a supervised connection, so it's a sensible
later upgrade — and because it is just another InboundChannel, adopting it
would not touch the capture pipeline at all.
Why a channel, not your self-DM¶
A bot token (xoxb) can only read conversations the bot belongs to, and Slack
gives it no route into the DM you have with yourself. Reading a self-DM requires
a user token (xoxp + im:history) which can read your entire Slack — a
much broader credential for a personal tool. A private channel with the bot
invited costs one habit change and keeps the token narrow.
Setup¶
- Create a private channel, e.g.
#alma-inbox, and invite the ALMa bot. - Add these bot scopes alongside the
chat:writealerts already use:groups:history+groups:read(private channel), orchannels:history+channels:read(public)reactions:write— the receipt on your message
- Set the channel in Settings → Plugins → Slack.
Capture stays off until a channel is nominated, and it is deliberately a
separate setting from the alert channel: polling the channel ALMa posts to
would bury your captures under digests. It is not a technical requirement —
the poller skips messages carrying a bot_id, so ALMa cannot re-read its own
alerts.
What you see on your phone¶
| Reaction | Meaning |
|---|---|
📥 :inbox_tray: |
Captured — it's in your Inbox. |
📚 :books: |
You already have this in your Library. |
❓ :question: |
Couldn't identify a paper. A thread reply says why. |
⚠️ :warning: |
Something failed upstream; it will be retried. |
A successful capture is silent apart from the reaction. ALMa only speaks up in-thread when something needs you.
What gets recognised¶
DOIs in any form (bare, doi:, https://doi.org/…), arXiv links and ids
(converted to their registered 10.48550/arXiv.* DOI, which OpenAlex indexes),
bioRxiv DOIs, OpenAlex work ids, and a bare publisher URL as a last resort.
Prose around the link is ignored.
Adding an integration¶
Implement InboundChannel inside a package under alma.plugins, expose it as
the manifest's inbound_factory, declare receive, then register that manifest
once in alma.plugins.registry.PLUGINS. Resolution, corpus landing, Inbox
membership, idempotency, and cursor handling are already core.
See Building an integration for the complete schema, activation, secret-storage, and test contract.
Related¶
- Paper lifecycle — the two state axes
- Home — where Inbox items surface
- Library — where triaged papers land
- External integrations — adapters for both directions