Poindexter CLI Reference
Last Updated: 2026-09-28 Thepoindexter command is installed as a console script when you
pip install -e src/cofounder_agent. It’s the primary operator
interface for everything you’d otherwise do by hand with psql or
curl.
Operator hosts that run the stack from a deploy clone (Linux) install the CLI withAll commands takebash ~/.poindexter/deploy/glad-labs-stack/scripts/linux/install-host-cli.shinstead. That makespoindexterrun the deployed code, the same tree the worker bind-mounts, and keeps its dependencies on the deployedpoetry.lock. An editable install of a working checkout runs that checkout’s code. That’s right for developing the CLI and wrong for operating the stack: many commands call service code in-process, so a checkout that has fallen behindmainquietly runs old behaviour. See the host CLI surface.
--help for inline documentation. This page is the
consolidated reference.
Quick reference
Run
poindexter --help for the top-level list and
poindexter <group> --help for subcommands.
Deprecated flat aliases (#1652). The former top-level verbsapprove/reject/list-pending/show-pending(now undergates),approve-publish/reject-publish/list-pending-publish/show-pending-publish(now underschedule),publish-at(nowschedule at), and the singularpostgroup (nowposts) still work as hidden, deprecated aliases — each prints a one-line deprecation notice to stderr and delegates to its grouped command. Update scripts to the grouped form; the aliases will be removed in a later cleanup.
setup
First-run wizard. Generates secrets, tests DB connectivity, and writes
~/.poindexter/bootstrap.toml.
--auto must run from a Poindexter checkout: it starts the
postgres-local service of the compose file scripts/start-stack.sh
launches (in the same compose project) and points bootstrap.toml at
it, so the CLI and every container share one database. It also
generates every secret that compose file requires, including
poindexter_secret_key. --force never regenerates a secret the file
already holds — Postgres keeps the password its volume was initialised
with, and encrypted settings need the key they were written with. Use
--check in CI to validate that an operator’s config is well-formed.
memory
Semantic memory store (pgvector). Shared spine for all agents.
memory search <query>
Semantic search across the memory store. Top-k cosine similarity.
--writer TEXT— filter by writer (e.g.brain,claude,operator)--source-table TEXT— filter by source (brain,audit,posts,memory,claude_sessions,issues)--min-similarity FLOAT— default 0.5--limit INT— default 5--json— machine-readable JSON
memory status
Aggregate counts per source_table and per writer.
memory store
Store a memory note.
--text TEXTor--file PATH— content source (pick one)--writer TEXT— required--source-id TEXT— optional ID to link back--source-table TEXT— defaults tomemory--tags TEXT— comma-separated
memory embed <text>
Print the raw 768-dim embedding vector for debugging.
memory backfill-posts
Re-embed all published posts into pgvector (source_table=‘posts’).
tasks
Content pipeline task queue.
tasks list
tasks get <task_id>
tasks create <topic>
Queue a new content task.
--niche names the niche the post belongs to (poindexter topics niche list;
a fresh install seeds starter-blog). A post can only be published once its
task has one: without it the task still generates and tasks approve still
succeeds, but nothing is staged for publishing, and approve prints a warning
saying so. An unknown slug fails the request with the list of known niches.
It first waits for the worker API to answer — up to
--wait-for-worker seconds (default 180; 0 fails fast) — because the
worker spends about a minute booting after start-stack.sh up -d, and the
quick start queues a task right after starting the stack.
tasks approve <task_id> / tasks reject <task_id>
--feedback is required on reject — the worker API enforces it.
--retry (the default, with a warning when omitted) sets
rejected_retry: the regen flow re-claims the task, re-runs the
pipeline with your feedback, and it lands back in awaiting_approval.
--final sets rejected_final: terminal, no regen.
Changed your mind after a --retry? Re-run the reject with
--final — the endpoint permits the rejected_retry → rejected_final
escalation (and re-finalizing a rejected_final task is an idempotent
no-op), so a duplicate/off-brand topic can be closed out without a
manual DB update. The escalation races the regen claim: once the flow
has picked the task up (in_progress) you get a 409 — wait for it to
land back in awaiting_approval and reject --final there.
Re-rejecting an already-rejected task with --retry stays a 409;
re-queueing is the regen flow’s job.
tasks publish <task_id>
Manually publish an approved task (normally handled automatically on
approval, but this is the manual override).
posts
Published/draft blog post management.
posts create
Upload a finished markdown post you wrote by hand — the manual counterpart
to the AI pipeline (the create_post MCP tool / POST /api/tasks, which
generates a post from a topic). You supply the body; a complete posts row is
inserted directly and no writer LLM runs.
--from-file PATH or stdin); an empty body fails
loud. Flags:
--from-file PATH— markdown file to upload. Omit to read the body from stdin.--title TEXT— post title. Omit to derive it from the first markdown# H1in the body; if neither is present the command fails loud.--slug TEXT— URL slug. Omit to derive from the slugified title plus a short random suffix.--niche SLUG— stored inmetadata.niche_slug(e.g.ai_ml,gaming,pc_hardware). Omitting it warns — niche-gated publishing skips a post with no niche.--excerpt TEXT— optional excerpt / SEO description.--status {draft,awaiting_approval}— initial status (defaultdraft).awaiting_approvalroutes it into the approval queue.--media A,B— media this post wants (podcast,video,video_short;shortis an alias forvideo_short). Defaults todefault_media_to_generate. Recorded only, not yet rendered: the media pipeline renders from a source pipeline task and its Stage-1 scripts, which a hand-written post doesn’t have (stack#4099). For a podcast today, publish the post and callPOST /api/podcast/generate/{post_id}.--force— bypass the semantic dedup guard and idempotency.--json— machine-readable output.
- Semantic dedup — the resolved title is checked against already-published
posts and refused if cosine similarity ≥
create_post_dedup_threshold(default0.75). Re-run with--forceto override. - Idempotency (#338) — a key derived from
body + operatormakes an accidental same-file re-upload withincli_post_create_idempotency_window_minutes(default 30) return the existing post instead of a duplicate; an edited body is a new post.--forcebypasses it, or setcli_post_create_idempotency_enabled=falseto disable.
posts list
posts search <query>
posts get <slug>
posts publish <post_id> / posts archive <post_id> / posts unpublish <post_id>
unpublish to roll back a wrong/bad publish right away — it reverts
the post to draft and pulls its JSON from storage + revalidates, so the
page stops being served immediately (unlike archive/the PATCH status flip,
which only changes the column and waits for the next rebuild to drop the post).
Idempotent: a post that isn’t currently published reports No change. Also
exposed as POST /api/posts/{post_id}/unpublish.
posts retitle <post_id> <title>
settings
Read and write app_settings — the DB-first config plane.
settings list
settings get <key>
--reveal decrypts an is_secret row and prints the bare plaintext to
stdout (a warning goes to stderr), so it’s safe to capture in a script —
e.g. a webhook HMAC test-fire. Without --reveal, encrypted values stay
redacted as ******* (encrypted).
settings set <key> <value>
Upsert — creates if missing (with --allow-new), updates if present.
--category and --description are written only when you pass them.
A bare settings set <key> <value> changes the value (and re-activates a
disabled row) and nothing else:
--description "" clears a description (not with --secret, whose write
always keeps an existing one); --category general files a key under
general on purpose. Both used to be silently defaulted (general /
empty), which re-filed every key you touched and blanked its description —
see the history note in settings_set’s docstring.
Category is display-only (it groups the console’s settings sidebar), and
the worker re-derives every row’s category from its key on each start
(services/settings_categories.py, applied by seed_all_defaults). So an
explicit --category that the resolver disagrees with holds only until the
next worker start. To file a key somewhere permanently, add an override or
prefix rule to settings_categories.py instead.
--secret stores the value encrypted at rest (pgcrypto) and flags the
row is_secret=true, routing through the same path as the per-surface
<surface> set-secret commands. The plaintext is never echoed back, and
--allow-new is implied (operator credentials aren’t phantom-key-guarded).
A new secret is filed under secrets unless you pass --category; an
existing row always keeps its category (the encrypted write never
re-files a row), and a differing --category prints a warning saying it
was not applied. The confirmation line reports the category the row actually
holds. Read one back with settings get <key> --reveal.
settings disable <key> / settings enable <key>
Soft-delete a setting (is_active=false) while preserving the value.
costs
Spend and operational metrics.
costs budget
Month-to-date spend vs. the configured monthly budget.
costs operational
Task counts, worker state, websocket connections.
vercel
Vercel deployment status via the Vercel REST API. Requires
vercel_token in app_settings.
pro
Operate the Poindexter Pro pay→deliver chain (glad-labs-stack#3216):
Lemon Squeezy subscriptions in, GitHub collaborator access to the private
poindexter-pro repo out. The scheduled sync handles the steady state;
these commands are for status checks and the manual username-link path.
poindexter premium(machine-side license-key activation) was retired 2026-08-24 — it stampedpremium_*settings that nothing read. There is no license key to activate anymore; buying Pro gets you a GitHub invite. The old name survives one release as a hidden stub that prints this pointer and exits non-zero.
pro status
pro sync
Runs one reconcile pass NOW (ignores pro_delivery_enabled — invoking
the command is the operator intent).
pro link <sub> <github-username>
Attaches a buyer’s GitHub username and delivers access immediately.
<sub> is a subscription id, id prefix, or customer email — this is the
manual half of delivery when Lemon Squeezy doesn’t hand the sync a
username (the pro_delivery_action_needed finding names the exact
command to run).
pro unlink <sub>
Revokes repo access and detaches the GitHub account.
newsletter
Operate the newsletter signup path. The public site’s form captures each
signup into a Resend segment (the site cannot reach the worker, which has no
public ingress), and SyncNewsletterAudienceJob pulls that segment into
newsletter_subscribers every 15 minutes. These commands run the same
service functions now. Design and opt-out rules:
newsletter-signup-capture.md.
newsletter sync
Pulls the segment named by resend_audience_id now. New contacts become
subscribers, opt-outs made in Resend are applied, and an address unsubscribed
here is never re-subscribed. --dry-run reports what would change and writes
nothing; run it before a backfill. Exits non-zero on any error.
newsletter canary
Signs a Resend test inbox up through the public signup endpoint, checks with
the worker’s key that it reached the segment the worker pulls, then deletes
it. Each run sends one welcome email to the test inbox. Without --url it
uses newsletter_signup_canary_url, which also switches on the daily
ProbeNewsletterSignupJob. Exits non-zero, naming the cause, when the signup
is not captured.
topics
Operator interface for the topic-decision approval queue
(Glad-Labs/poindexter#146). When
pipeline_gate_topic_decision = on, anticipation_engine output and
manual proposals both land in the same queue rather than auto-running.
Drain the queue at your own pace.
The gate is opt-in. Enable it with:
poindexter gates list shows two groups: the DEFAULT PUBLISH GATE
— the always-on awaiting_approval per-post sign-off that actually holds
every post, with its live pending count and the global auto-publish posture
(auto_publish_threshold, require_human_approval, and any armed niches) —
and the CONFIGURABLE GATES table, each toggleable gate with its STATE
(enabled/disabled), WIRED INTO (where it fires, e.g. canonical_blog,
seo_refresh, scheduled_publisher), and PENDING count. --json emits the
full rows (a superset of the table, including each gate’s mechanism:
graph-node / imperative-hold / default).
With topic_decision off, manual topics propose calls land at
status='pending' and the worker runs them end-to-end exactly like
auto-discovered topics — the gate is purely additive.
topics list [--source NAME] [--json]
Show every topic currently paused at topic_decision. Oldest-first so
you work the queue chronologically.
TASK_ID (first 8 chars of UUID), AGE (relative —
5m, 2h, 1d), SOURCE, TOPIC. Empty queues exit zero with a
friendly message — that’s a normal state, not an error.
topics show <task_id> [--json]
Pretty-print the full artifact for one queued topic. The artifact
shape:
topics approve <task_id> [--feedback TEXT] [--json]
Approve a queued topic — flips it to pending and resumes the
pipeline.
poindexter gates approve <task_id> --gate topic_decision with
the gate name asserted explicitly so a misrouted task fails loudly.
topics reject <task_id> [--reason TEXT] [--json]
Reject a queued topic — flips it to dismissed (the topic-decision
gate’s reject status) and ends the task.
topics propose --topic "..." [flags]
Manually inject a topic into the queue. Lands the same way an
anticipation_engine auto-proposal would — at
awaiting_gate='topic_decision' when the gate is on, otherwise at
status='pending'.
--topic TEXT(required) — non-empty topic string.--keyword TEXT— primary SEO keyword. Falls back to the first tag.--tags A,B,C— comma-separated tag list.--category SLUG— category hint (hardware,ai-ml,gaming).--source LABEL— origin label recorded on the artifact (defaultmanual); use this to tag queue items by sub-source if you’ve got several injection paths feedingtopics propose.--target-length N— target word count for the eventual draft (default1500).--style/--tone— pipeline parameters (defaultstechnical/professional).--json— machine-readable output.
topic_discovery_max_pending (default 50)
when the gate is enabled — drain pending topics first. Empty topic
text exits non-zero rather than silently inserting a junk row.
Worked example — drain the queue
Telegram-side flow
When a topic lands in the queue, the operator gets a Telegram message (iftelegram_ops is configured in webhook_endpoints) with the
artifact summary. The reply path mirrors the CLI:
/approve_topic <task_id>— equivalent totopics approve./reject_topic <task_id> [reason]— equivalent totopics reject.
mcp-server-gladlabs topics_approve / topics_reject tools the
CLI uses. Single source of truth: services.approval_service /
services.topic_proposal_service.
schedule
Operator interface for the scheduled-publishing queue
(Glad-Labs/poindexter#147). Poindexter already has a
background loop (services/scheduled_publisher.py) that publishes
posts whose posts.published_at slot has arrived and whose
status='scheduled'. These commands populate, inspect, and rewrite
that queue. The group also hosts the publish-approval gate verbs
(approve / reject / pending / show-pending) for the post-scheduling
HITL gate.
A post becomes eligible for batch scheduling once it has been
approved (status IN ('approved', 'awaiting_approval')) and has no
publish slot yet (published_at IS NULL). The batch command pulls
the oldest-approved-first by default; pass --ordered-by to change
the source ordering.
schedule at <post_id> <when>
Schedule a single post. (Formerly the top-level publish-at, which
still works as a hidden deprecated alias.)
--in DUR— relative scheduling (30m,2h,1d,1h30m).--force— overwrite an existing schedule.--json— machine-readable output.
schedule approve / reject / pending / show-pending
Operate the final_publish_approval gate on the posts table — the operator’s
last veto before a scheduled post is auto-promoted to published. When the
gate is enabled, scheduled_publisher pauses each due scheduled post here
instead of publishing it (writing posts.awaiting_gate), notifies the operator,
and waits; approve clears the gate and the next publisher tick (≤60s) promotes
it. This gate only fires on the scheduled→published timer path — the manual
approve→go-live flow is already a human action and is unaffected. Formerly the
top-level approve-publish / reject-publish / list-pending-publish /
show-pending-publish, which still work as hidden deprecated aliases.
poindexter gates set final_publish_approval on.
schedule batch
Bulk-assign slots to the approved queue.
--count N(required) — how many posts to schedule.--interval DUR(required) — slot spacing (30m,1h,1h30m,1d).--start TIME(required) — first slot. ISO 8601,now,tomorrow 9am, ornext monday 14:00.--quiet-hours HH:MM-HH:MM— slots inside the window are skipped to the next allowed time. Falls back to thepublish_quiet_hoursapp_setting when omitted.--ordered-by—approved_at(default) |created_at|id|title.--force— re-schedule posts that already have a slot.--json— machine-readable output.
schedule list [--all] [--json]
Print the upcoming queue in publish-time order. Pass --all to
include past schedules.
schedule show <post_id> [--json]
Schedule + status detail for a single post.
schedule shift <post_id> --by DUR / schedule shift --all --by DUR
Push one schedule (or every still-future scheduled post with --all)
back by DUR. Past slots are intentionally untouched — shifting an
already-published post does not un-publish it.
schedule clear <post_id> / schedule clear --all
Drop the schedule on a single post (or every still-future scheduled
post). Clearing rolls the row back to status='approved' so it can
be re-scheduled.
Worked examples
1. Schedule 20 approved posts to publish every 30m starting tomorrow at 9am, skipping 22:00-07:00 quiet hourspoindexter schedule list
prints publish-time-ordered rows.
3. Push the entire queue back by 1 hour
published_at advanced by
one hour. The audit trail records the operation as a single
schedule.shifted event with the post-id list in the payload.
migrate
Schema migration runner (Glad-Labs/poindexter#226). The
worker auto-runs migrations on boot, but operators who don’t restart
their container can have unapplied migrations sitting on disk. This
group exposes the same runner as a direct CLI surface — no worker
round-trip, no container restart.
The CLI operates on the same services/migrations/ directory and the
schema_migrations tracking table the worker uses, so it’s safe to
mix and match (CLI migrate up then a worker reboot, or vice versa).
migrate status [--json]
List every migration file on disk alongside its applied state.
0000_baseline.py is the only legacy 4-digit file remaining — it
captures the squashed history (collapsed into one baseline, re-rolled
most recently by the Phase F squash on 2026-06-22). Every NEW migration uses a UTC timestamp
prefix YYYYMMDD_HHMMSS_<slug>.py per Glad-Labs/poindexter#378
(2026-05-05). See migrations.md for the naming
convention.
migrate up [--to NAME] [--json]
Apply all pending migrations. Idempotent — already-applied migrations
are skipped via schema_migrations.
--to accepts either a timestamp prefix (YYYYMMDD_HHMMSS) or a full
filename. The summary line at the end reads
applied N, skipped M, failed K, pending P.
migrate down [--to NAME] [--all] [--yes] [--json]
Roll back applied migrations. Migrations are rolled back in reverse
order. Without flags, only the most recent applied migration is
rolled back.
async def down(pool)— pool-based, the modern conventionasync def rollback_migration(conn)— connection-based, the older convention
migrate down skips it and exits
non-zero with a no down()/rollback_migration() defined — skipped
notice. The schema_migrations row is left intact in that case so
nothing silently disappears from the tracker.
--all and --to are mutually exclusive. --all prompts for
confirmation unless --yes is set or --json is set (JSON mode
suppresses the interactive prompt).
Environment variables
The CLI respects a small set of env vars for non-interactive use (the staticPOINDEXTER_TOKEN bearer was removed with OAuth 2.1, poindexter#249):
Everything else comes from
~/.poindexter/bootstrap.toml and the
app_settings DB table.
JSON output mode
Every list/search/get subcommand supports--json. Use it for
piping into jq or building shell automation: