Skip to main content

Poindexter CLI Reference

Last Updated: 2026-09-28 The poindexter 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 with bash ~/.poindexter/deploy/glad-labs-stack/scripts/linux/install-host-cli.sh instead. That makes poindexter run the deployed code, the same tree the worker bind-mounts, and keeps its dependencies on the deployed poetry.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 behind main quietly runs old behaviour. See the host CLI surface.
All commands take --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 verbs approve / reject / list-pending / show-pending (now under gates), approve-publish / reject-publish / list-pending-publish / show-pending-publish (now under schedule), publish-at (now schedule at), and the singular post group (now posts) 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.
Flags:
  • --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.
Flags:
  • --text TEXT or --file PATH — content source (pick one)
  • --writer TEXT — required
  • --source-id TEXT — optional ID to link back
  • --source-table TEXT — defaults to memory
  • --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.
The body is required (from --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 # H1 in 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 in metadata.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 (default draft). awaiting_approval routes it into the approval queue.
  • --media A,B — media this post wants (podcast, video, video_short; short is an alias for video_short). Defaults to default_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 call POST /api/podcast/generate/{post_id}.
  • --force — bypass the semantic dedup guard and idempotency.
  • --json — machine-readable output.
Two safety rails, shared with the AI path:
  • Semantic dedup — the resolved title is checked against already-published posts and refused if cosine similarity ≥ create_post_dedup_threshold (default 0.75). Re-run with --force to override.
  • Idempotency (#338) — a key derived from body + operator makes an accidental same-file re-upload within cli_post_create_idempotency_window_minutes (default 30) return the existing post instead of a duplicate; an edited body is a new post. --force bypasses it, or set cli_post_create_idempotency_enabled=false to disable.

posts list

posts search <query>

posts get <slug>

posts publish <post_id> / posts archive <post_id> / posts unpublish <post_id>

Reach for 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>

Useful when you need to fix a title without going through the full approval queue again (e.g. remove a first-person pronoun caught late).

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 stamped premium_* 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

Shows delivery config presence, subscription counts, and recent subscription rows.

pro sync

Runs one reconcile pass NOW (ignores pro_delivery_enabled — invoking the command is the operator intent).
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).
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.
Output columns: 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.
Alias for 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'.
Flags:
  • --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 (default manual); use this to tag queue items by sub-source if you’ve got several injection paths feeding topics propose.
  • --target-length N — target word count for the eventual draft (default 1500).
  • --style / --tone — pipeline parameters (defaults technical / professional).
  • --json — machine-readable output.
Refuses to propose past 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 (if telegram_ops is configured in webhook_endpoints) with the artifact summary. The reply path mirrors the CLI:
  • /approve_topic <task_id> — equivalent to topics approve.
  • /reject_topic <task_id> [reason] — equivalent to topics reject.
The Telegram bot is OpenClaw, which calls into the same 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.)
Flags:
  • --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.
Enable the gate like any other: poindexter gates set final_publish_approval on.

schedule batch

Bulk-assign slots to the approved queue.
Flags:
  • --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, or next monday 14:00.
  • --quiet-hours HH:MM-HH:MM — slots inside the window are skipped to the next allowed time. Falls back to the publish_quiet_hours app_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.
If no posts are eligible the command exits non-zero with an informative message — no silent no-op.

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 hours
Slots that would land between 22:00 and 07:00 jump forward to the next 07:00; subsequent slots step from there. So if the 11th slot would be 02:00, it becomes 07:00 the next morning, and slots 12, 13… continue at 07:30, 08:00, … 2. Show what’s scheduled in the next 7 days
For a quick human-readable view, plain poindexter schedule list prints publish-time-ordered rows. 3. Push the entire queue back by 1 hour
Every post still in the future has its 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.
Sample output:
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.
A migration’s rollback path is whichever of these the migration module exposes (in priority order):
  • async def down(pool) — pool-based, the modern convention
  • async def rollback_migration(conn) — connection-based, the older convention
If a migration defines neither, 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 static POINDEXTER_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:
Pretty-printed tables are the default for humans. JSON is for scripts.