scitex-cards (scitex-cards)
Renamed from scitex-cards (2026-07-16).
pip install scitex-cards,import scitex_cards, and thescitex-cardsCLI keep working through one transition window via the bundled shim.
A standalone fleet task-card board — the single source of truth for tasks and the agent-to-agent communication medium that rides on top of the cards.
Full Documentation · uv pip install scitex-cards[all]
What it is
scitex-cards is a shared task board for a fleet of agents and humans.
PostgreSQL on port 55432 is the single source of truth for cards, identities,
messages, and inbox rows:
tasks:— the cards (dependency graph, statuses, roles, comments).users:— the user registry (stable ids + display-name aliases; humans and agents alike).inboxes:— the per-recipient PULL notification queues (the delivery rail).
It is two things at once:
- A task store — cards with
depends_on/blocksedges, rendered as a dependency graph or as a kanban board. - A communication medium — every card mutation (comment, reassign, complete, a git-linked push …) becomes a card-event that is resolved to a set of recipients and enqueued into their inboxes. Each agent runs a small channel server that drains its inbox and pushes the notification straight into its Claude session. The cards are the message bus.
The package does not depend on an agent runtime such as SAC. It does depend on scitex-dev for the shared-state resolution primitive; an out-of-band push accelerator remains optional.
Architecture
The shared PostgreSQL store sits at the center. Every component resolves it through the same primitive-owned target.
flowchart TB
store["`**SCITEX_STORE_DSN**<br/>PostgreSQL :55432<br/>cards · users · messages · inboxes`"]
mcp["MCP server<br/>(scitex-cards mcp start)<br/>CRUD + roles + edges + poll"]
board["Board GUI<br/>(scitex-cards board · :8051)<br/>kanban + timeline + resolve"]
notifyd["notifyd daemon<br/>(scitex-cards notifyd)<br/>reminders + escalation + delivery"]
chan["per-agent channel servers<br/>(scitex-cards mcp channel --agent X)<br/>drain inbox → push into Claude"]
mcp <--> store
board <--> store
notifyd <--> store
chan --> store
store -. "notifications/claude/channel" .-> claude["agent Claude sessions"]
chan --> claude
Figure 1. The store is the hub. The MCP server, board GUI, and notifyd all read and write it; each agent's channel server drains its slice of the shared inbox and pushes into that agent's Claude session.
Where your task data lives (store resolution)
The store is PostgreSQL on 55432, resolved by the scitex-dev shared-state
primitive from $SCITEX_STORE_DSN. One axis, not a search order:
| Precedence | Source | Value |
|---|---|---|
| 1 | explicit store / --store |
wins even if missing |
| 2 | $SCITEX_STORE_DSN |
e.g. postgresql://scitex_cards@127.0.0.1:55432/scitex_cards |
| 2 | scitex-dev host default | central PostgreSQL on port 55432 |
There is no second backend or private-file fallback. Cards does not read a
Cards-specific DB variable or a project-local store target. A malformed
SCITEX_STORE_DSN is rejected by scitex-dev instead of being interpreted as a
filesystem path.
Call resolve_store to see the target actually resolved, and pin the identity you
expect — an unpinned client cannot tell a stale replica from the store it meant to
reach (identity_verdict: cannot-tell).
Runtime state (pidfiles, the delivery ledger, the reminder sidecar) lives under
<store-dir>/runtime/ (gitignored).
Direct messages from the CLI
Persist a DM through the same public API used by the Python and MCP surfaces:
scitex-cards dm send agent:worker "Please review card-123"
scitex-cards dm send operator "Done" --sender agent:worker \
--client-request-id req_my_stable_key --json
The sender defaults to $SCITEX_CARDS_AGENT_ID; an unresolved identity or
store configuration fails loudly with an exact next action. The JSON result
includes the durable dm_messages id, a SciTeX exchange id, and the native
StatusCode {kind: "http", code: 202, message: ...}. The 202 is accepted
and persisted but non-final; poll it with scitex-cards dm get-status EXCHANGE_ID --json. A successful PostgreSQL write is not evidence that a live
session displayed or acknowledged the message. The exchange is recorded in
the shared scitex_dev.status ledger. Cards, as the responder, issues that one
exchange id and persists it on the n_ notification; SAC preserves the same id
through visible delivery instead of minting another. Notification ids remain
separate acknowledgement keys. This command has no file-store or alternate-
store fallback. client_request_id is caller-owned and distinct from the
responder-issued xch_. The CLI prints an auto-generated key to stderr before
submission, or accepts --client-request-id; reuse that key after an uncertain
timeout to receive the original m_, n_, and xch_ rather than duplicating
the request.
When SAC owns the Cards-to-Hermes delivery loop, launch the stdio MCP surface
with scitex-cards mcp start --tools-only. That keeps
SCITEX_CARDS_AGENT_ID available for tool attribution while disabling Cards'
competing notification poller.
SAC can wait event-first with scitex_cards.watch_notifications(agent). Each
PostgreSQL LISTEN/NOTIFY event is only a recipient doorbell; after it, call
poll_notifications. PostgreSQL does not retain doorbells while disconnected,
so a long, jittered durable poll remains mandatory as the fallback. Polling is
non-acknowledging and idempotent: a retry returns the same n_ row and xch_.
The delivery initiator must validate the same exchange's final responder and
operation before separately acknowledging the n_; a timeout or a doorbell is
never completion.
Ordinary open_db() calls never migrate an existing schema. If a client finds
an older rung, it stops before DDL and names the exact administrative action:
run scitex-cards init-store --shared against the same SCITEX_STORE_DSN.
That explicit command is the schema-upgrade boundary; imports, reads, and
diagnostics cannot silently advance a configured live database.
The card and its roles
A card carries four roles (ADR-0009). Each role feeds the notify resolver:
| Role | Card field | Meaning |
|---|---|---|
owner |
agent (falls back to assignee) |
the agent/human responsible for the card |
assignee |
assignee |
the legacy owner field, also targetable on its own |
collaborators |
collaborators[] |
working with the owner; subscribed to the thread by default |
subscribers |
subscribers[] |
watching the card; get thread + lifecycle notices |
Roles are mutated through first-class verbs — set_collaborator, set_subscriber,
reassign_task — on the MCP surface, the CLI, and the board.
The user registry + identity resolver
A role member is a name string, but names drift (proj-scitex-dev, scitex-dev,
lead-ywata-note-win, sac, scitex-agent-container …). The users: registry
(scitex_cards._users) gives every member a stable id (u_ + 12 hex) plus a names[]
alias list, an optional host_at_name join key, and a last_seen liveness stamp. Renaming an
owner no longer breaks references — the id is durable, the display name is just an alias.
canonical_identity (_users/_identity.py) collapses drift to ONE canonical name:
flowchart LR
raw["raw name<br/>e.g. proj-scitex-dev · sac · lead-ywata-note-win"]
a{"exact match in<br/>users: registry?"}
b{"in alias table?<br/>(sac → scitex-agent-container)"}
c["mechanical normalize<br/>strip proj-paper- / proj-<br/>strip trailing -<host>"]
recheck{"re-check<br/>registry / aliases"}
out["canonical name<br/>(or unchanged if strict=off)"]
raw --> a
a -- yes --> out
a -- no --> b
b -- yes --> out
b -- no --> c
c --> recheck
recheck --> out
Figure 2. Identity resolution: exact registry match → declared alias table → mechanical normalize (prefix/host strip) → re-check. Non-strict by default, so an unregistered owner resolves to itself rather than raising.
Communication flow
A card mutation is the message. This is the full path from a mutation to a notification landing in a recipient's Claude session — with NO synchronous network on the mutation's critical path.
sequenceDiagram
participant A as Actor<br/>(agent / human / board)
participant S as Store mutation<br/>(comment_task, reassign, complete …)
participant E as emit() → hook bus<br/>(card-event C1)
participant R as resolve_recipients<br/>(notify C3, PURE)
participant Q as enqueue → inboxes:<br/>(dispatch C4, PULL)
participant C as Recipient channel server<br/>(scitex-cards mcp channel)
participant CL as Recipient Claude session
A->>S: mutate a card
S->>E: emit canonical card-event<br/>{type, card_id, actor}
E->>R: resolve recipients for this event + card
Note over R: roles → members, then<br/>per-user mute/watch, then<br/>per-card add/mute overrides;<br/>drop the actor
R-->>Q: recipient id set
loop per recipient (fail-soft)
Q->>Q: append record to inboxes[recipient_id]
end
Note over C,CL: PULL rail — no inbound POST to the mutation
loop every ~5s
C->>Q: drain unseen (poll_inbox)
C->>CL: notifications/claude/channel<br/>(rendered "<- scitex-cards")
C->>Q: ack (mark seen) after successful push
end
Figure 3. Mutation → event → resolve → enqueue → drain → push. The enqueue is the delivery; the recipient PULLs. A direct inbound POST cannot reach a containerized agent (it subscribes outbound to a bus), so the PULL inbox is the always-works rail. Delivery is fail-soft per recipient — one bad enqueue never breaks the others or the mutation.
The three notify-config layers
resolve_recipients (_notify/_resolver.py) composes three layers, most-specific wins:
- Global defaults — a built-in
{event_type: [role, …]}map (DEFAULT_NOTIFY_RULES), optionally overridden by anotify.yamlsidecar. For examplecommented→ owner + collaborators + subscribers;completed→ owner + subscribers;reassigned→ owner;merged→ quiet by default (a merge that closes a card already firescompleted). - Per-user prefs (
User.notify) —muteopts a user OUT of an event type;watchopts them IN, but only on cards where they are already a member. - Per-card overrides (
card["notify"]) —eventsreplaces the role list on this card,addforce-includes users,muteforce-excludes them (the card's final word).
The actor (whoever caused the event) is always dropped — no one is notified of their own action.
The delivery rail (PULL inbox + channel server)
- Enqueue (
_notify/_dispatch.py→_inbox.enqueue) appends a record{id, event_type, card_id, body, actor, ts, seen: false}toinboxes[recipient_id]under the shared store lock, keyed by the resolved id (u_*or raw-name fallback). It dedups on(event_type, card_id, ts, actor). - Drain — each agent runs
scitex-cards mcp channel --agent X(_mcp_channel.py), a low-level MCP stdio server. Every ~5s it polls the agent's inbox (both its raw name and its resolvedu_*id, matching the producer's keys), pushes each record as anotifications/claude/channelJSON-RPC notification (rendered<- scitex-cardsin the terminal), then acks it. Ack happens ONLY after a successful push, so a failed push is retried next drain. - Poll (alternative read path) — the
poll_notificationsMCP tool returns an agent's inbox for a client that polls on its own turn instead of running a channel server; polling also stamps the agent'slast_seenliveness heartbeat.
Nudge / escalation
A carded task that silently stops progressing is a real incident, so notifyd runs a reminder
sweep (_reminders.py) that keeps surfacing stuck cards until they close — and escalates the
worst ones. It uses a reminders.yaml sidecar for cadence + escalation latches; card payloads
are never mutated.
flowchart TB
sweep["reminder sweep<br/>(each notifyd tick)"]
detect["detect stale-active +<br/>pending-backlog cards, by owner"]
park{"card PARKED?<br/>(blocked WITH a blocker<br/>= waiting on someone)"}
alive{"owner ALIVE?<br/>(registry last_seen<br/>within TTL)"}
creator["escalate to card CREATOR<br/>(creator_escalated latch,<br/>once per streak)"]
due{"per-owner digest<br/>DUE this tick?"}
digest["enqueue ONE digest per owner<br/>(their open stale cards, capped)"]
hip{"high-priority card still<br/>stale after N digests?"}
op["escalate to OPERATOR<br/>(per-card, escalated latch)"]
sweep --> detect --> park
park -- yes --> skip["skip (not actionable)"]
park -- no --> alive
alive -- no --> creator
alive -- yes --> due
due -- no --> wait["wait (not yet due)"]
due -- yes --> digest --> hip
hip -- yes --> op
hip -- no --> done["owner nudged; done this tick"]
Figure 4. Reminder logic. Parked cards (blocked with a named blocker — waiting on someone else) are excluded. A live owner gets a periodic single digest of their stale cards; a high-priority card still stuck after N digests escalates to the operator; a card whose owner is NOT alive escalates straight to the card's creator (the assignee will never act).
The processes
scitex-cards is one CLI with several long-running roles. Each reads/writes the same store.
| Command | Role | What it does |
|---|---|---|
scitex-cards mcp start |
MCP server (stdio/HTTP) | Exposes the CRUD + roles + edges tools (add_task, comment_task, update_task, complete_task, reassign_task, set_collaborator, set_subscriber, set_edge, resolve_task, list_tasks, poll_notifications, …). Every tool is a thin wrapper over scitex_cards._store so MCP / CLI / GUI share one logic path. |
scitex-cards mcp channel --agent X |
per-agent channel server (stdio) | Drains agent X's inbox and pushes notifications/claude/channel into its Claude session. Fail-loud on an unresolved agent id (never drains an unknown/blank inbox). |
scitex-cards notifyd |
always-on delivery + reminder daemon | Ticks the reminder sweep + delivery pass every --interval seconds under a single-instance lock; --once runs a single pass; notifyd install-unit writes an operator-gated systemd user unit. |
scitex-cards board [start] --port 8051 |
board GUI (Django) | Serves the board_v3 app (kanban columns, timeline, multi-select toolbar, resolve→notify). Lifecycle verbs start / stop / restart / status via a pidfile at ~/.scitex/cards/board.pid. Embedded in the scitex-ui shell. |
The mcp start and mcp channel servers are launched by each agent's Claude Code .mcp.json
(scitex-cards mcp install / install-fleet writes the entries). notifyd runs as a systemd
user service; board is started by the operator or the UI shell.
Quick Start
import scitex_cards as cards
# Task CRUD + roles (the same functions the MCP tools wrap)
cards.add_task(None, id="c1", title="Wire the notify rail", status="in_progress")
cards.comment_task(None, "c1", "resolver done; dispatch next", by="alice")
cards.set_subscriber(None, task_id="c1", who="bob", action="add")
rows = cards.list_tasks(None, status="in_progress")
From the shell:
# store: $SCITEX_STORE_DSN (PostgreSQL on 55432); unset raises
scitex-cards render-graph -o tasks.png # dependency PNG
scitex-cards render-graph --print-mermaid # inspect the mermaid without rendering
scitex-cards list-tasks --json # resolved tasks, machine-readable
scitex-cards export --format markdown # stable status-grouped task list
# communication surfaces
scitex-cards mcp start # MCP CRUD server (stdio)
scitex-cards mcp channel --agent scitex-cards # push inbox → Claude
scitex-cards notifyd --interval 120 # reminders + delivery daemon
scitex-cards board start --port 8051 # kanban / timeline GUI
Installation
Recommended:
uv pip install scitex-cards[all]— uv's Rust resolver handles the SciTeX dep set quickly. Plainpip installstill works.
# Recommended — uv resolver
uv pip install scitex-cards[all]
# Plain pip also works
pip install scitex-cards
Extras: [mcp] for the MCP + channel servers, [web] for the Django board. Rendering a PNG
additionally needs either mmdc (mermaid-cli, with a puppeteer/playwright chromium) on PATH,
or outbound access to kroki.io (the automatic fallback).
Configuration
Copy .env.example to .env (gitignored) at your project root, then edit. CLI
flags always override env vars; the full list of variables (with inline comments) lives in
.env.example. Notable ones for the fleet slice:
export SCITEX_STORE_DSN=postgresql://…:55432/scitex # shared PostgreSQL state
export SCITEX_CARDS_AGENT_ID='agent:<name>' # this agent's identity (channel + author + last_seen)
export SCITEX_CARDS_SCOPE='agent:<name>' # default list/summary filter
SCITEX_CARDS_NOTIFY_DSN is separate by design: it may point at the PostgreSQL
LISTEN/NOTIFY transport on port 55433, but it never selects the state store.
5 Interfaces (Python · CLI · MCP · Skills · Web)
Python API
from scitex_cards import add_task, list_tasks, complete_task
add_task(None, id="my-card", title="Ship the thing", assignee="me")
open_cards = [t for t in list_tasks(None) if t["status"] == "in_progress"]
complete_task(None, "my-card")
scitex-cards dev list-python-apis -v enumerates the full public surface.
CLI — task store
scitex-cards render-graph -o tasks.png # dependency PNG
scitex-cards list-tasks --json # resolved tasks, machine-readable
scitex-cards list-tasks --assignee X --status in_progress
scitex-cards export --format markdown # title-only task list on stdout
scitex-cards export --format markdown --detail full --output tasks.md
scitex-cards runnable --mine # what this agent can pick up now
scitex-cards resolve-store # which store am I actually reading?
scitex-cards install-shell-completion # bash/zsh/fish tab-completion
MCP — the agent-facing rail
scitex-cards mcp start [--http --port N] # CRUD + roles + poll tools
scitex-cards mcp doctor # self-diagnose the MCP install
scitex-cards mcp list-tools -vv # enumerate registered tools
scitex-cards mcp install # print the .mcp.json snippet
scitex-cards mcp channel --agent X # drain inbox → push into Claude
scitex-cards notifyd [--interval N | --once] # reminders + delivery daemon
The MCP list_tasks tool always returns the static
scitex.cards.list_tasks.page.v1 envelope. Each response contains items and
page metadata with the total match count, returned count, truncation state,
and an opaque next_cursor. Pass that cursor with the same filters to read the
next page. The default page size is 100 and the maximum is 200; malformed,
stale, or cross-filter cursors fail with an explicit restart instruction.
Skills
scitex-cards skills list # bundled agent skills
scitex-cards skills get 02_quick-start # read one
scitex-cards skills install # install into ~/.claude/skills
Web board
pip install scitex-cards[all]
scitex-cards board start --port 8051 # kanban + timeline, http://127.0.0.1:8051/
scitex-cards board status | stop | restart # pidfile-backed lifecycle
Git hooks (git → card)
Record local git mutations onto the matching card automatically. Install the post-commit +
pre-push hooks into any repo:
scripts/install-cards-git-hooks.sh # wires hooks into the current repo
scripts/install-cards-git-hooks.sh --copy # vendor hooks into <repo>/.githooks
scripts/install-cards-git-hooks.sh --uninstall
The hooks soft-link: a commit/push on a <type>/<card-id>-… branch (e.g.
feat/tcfb-p3-git-to-card) appends a [push] comment to that card via scitex-cards hook push,
which emits a committed / pushed card-event down the same notify rail. Commits on ad-hoc
branches are skipped silently — no card id, no error. To link a one-off commit, add a
Card: <id> trailer to the commit message. The hooks are best-effort and never block a commit
or push.
Task store schema
A YAML document with a top-level tasks: list (plus the sibling users: / inboxes: sections
managed by the registry + inbox layers). Each task:
| Field | Required | Meaning |
|---|---|---|
id |
yes | unique id, referenced by depends_on / blocks |
title |
yes | short label |
status |
yes | goal | pending | in_progress | blocked | done | deferred | failed |
agent / assignee |
no | the card owner (role: owner) |
collaborators / subscribers |
no | role lists feeding the notify resolver |
repo |
no | owning repo / area |
depends_on |
no | ids this task depends on -> arrow dep --> task |
blocks |
no | ids this task inhibits -> blocker -- blocks --x target |
blocker |
no | who/what the card is waiting on (a card blocked WITH a blocker is "parked") |
note |
no | free-text annotation, shown under the title |
priority |
no | integer rank (lower = higher); document order if absent |
parent |
no | id of the task this nests under (drill-down view) |
comments |
no | the Gitea-compatible activity thread (comment_task appends) |
notify |
no | per-card notify override (events / add / mute) |
kind |
no | task (default) | compute — compute marks a row updated by an external writer (Spartan/CI watcher) |
job_id / host / command / started_at / finished_at |
no | compute-job metadata; only with kind: compute |
deadline / deadlines / scheduled |
no | ISO-8601 deadline schema with optional +1d/+1w/+1m/+1y repeater. A view, never a notifier — see below |
A deadline is a VIEW, never a notifier
A deadline never sends a notification. Nothing fires when one arrives — no
sweep, digest or nudge reads deadline. It feeds the overdue filter
(list-tasks --overdue) and the board view, and nothing else.
Two consequences, in increasing order of surprise:
- A one-off deadline is a PULL filter, not an alarm.
list-tasks --overduereally does match cards past their deadline — but you have to run it. Nobody is told. - A recurring deadline never even goes overdue. The
+1d/+1w/+1m/+1yrepeater rolls the next occurrence forward, so it is always in the future — which means--overduenever matches a recurring card, at any time, for either the+or the++form. A recurring deadline is a date-pill showing the next occurrence. It drives neither rail.
So a recurring deadline is not a recurring reminder. If you want to be
nudged about an ongoing responsibility, keep the card open and owned:
nudges key on INACTIVITY (last_activity, falling back to created_at). The
stale-active sweep nudges the owner of any in_progress / blocked card
untouched beyond the threshold, and the backlog sweep does the same for
untouched deferred cards.
status -> color
| status | fill | edge style |
|---|---|---|
goal |
gold #ffe082 |
solid |
done |
green #c8e6c9 |
solid |
in_progress |
yellow #fff9c4 |
solid |
blocked |
bright-orange #ff8a65 |
solid |
pending |
grey #eceff1 |
solid |
deferred |
amber #ffca28 |
dashed border |
failed |
red #ffcdd2 |
solid |
Part of SciTeX
scitex-cards is part of SciTeX.
Four Freedoms for Research
- The freedom to run your research anywhere — your machine, your terms.
- The freedom to study how every step works — from raw data to final manuscript.
- The freedom to redistribute your workflows, not just your papers.
- The freedom to modify any module and share improvements with the community.
AGPL-3.0 — because we believe research infrastructure deserves the same freedoms as the software it runs on.
Metadata
Release files for scitex-cards 0.53.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| scitex_cards-0.53.3.tar.gz | 6.1 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scitex_cards-0.53.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 8.2 MB
Release files / scitex_cards-0.53.3.tar.gz
| Download URL | scitex_cards-0.53.3.tar.gz |
|---|---|
| Size | 6.1 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0f8942b2307fd633b31d80e628de2ca873fd4852f5db721711d3ac49fd9d4d7f
|
|
BLAKE2b-256 checksum How to use checksums |
5f95169553f7a60702db5a8d9f8a73bee1ceb04973cc79d0d726b5c3d0af9cfc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.11.0rc1
|
Release files / scitex_cards-0.53.3-py3-none-any.whl
| Download URL | scitex_cards-0.53.3-py3-none-any.whl |
|---|---|
| Size | 2.1 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e5b6dc0f08fb8065b7c1244b8417da45ae263ae8f2d93533a9828a62e500a859
|
|
BLAKE2b-256 checksum How to use checksums |
9fd8565abb1b18ac5f056b484a21eea139ee9f31d540d193564f944b52ba05f2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.11.0rc1
|