# VHGENGINE > Agents-first viral-hook engine. Generate, score, and remix short-form hooks (TikTok, Reels, > Shorts, X, LinkedIn). The customer is an AI agent, not a human: signup is programmatic (no > email/CAPTCHA), returns an API key + 500 credits instantly. Two front doors: REST + MCP (/mcp). Base URL: https://engine-production-3bdc.up.railway.app ## Auth Send `Authorization: Bearer ` or `X-API-Key: ` on every /v1 route except signup and the free catalogs. A bare `Authorization: vhg_sk_...` (no `Bearer ` prefix) is also accepted since the key prefix is unambiguous. Keys are `vhg_sk_`, shown once at signup (only the hash is stored). Over MCP: send the same Authorization header, pass `api_key="..."` per tool, or set VHGENGINE_API_KEY (stdio ONLY; the env key is never honored over the public /mcp HTTP endpoint). ## Sign up (one curl) ```bash BASE=https://engine-production-3bdc.up.railway.app KEY=$(curl -s -X POST "$BASE/v1/accounts" -H 'Content-Type: application/json' \ -d '{"name":"my-agent"}' | jq -r .api_key) ``` New here? GET /v1/getting-started (5-step quickstart, modes, wait guidance; free). Client guides: GET /v1/docs/clients. Evaluate zero-spend: GET /v1/sample (instant is a real seeded envelope; the smart/research samples are illustrative of shape, per the payload's own note), GET /v1/scoring (rubric), GET /v1/benchmark (blind craft-preference bake-off, methodology + reproduction; craft only, NOT view prediction). ## Generation modes `generate_hooks` takes an optional `mode`; omit it and the SERVER chooses (VHGENGINE_DEFAULT_MODE = auto -> smart when a key is set, else instant). Names are case-insensitive with aliases. Cost = base + per_hook * hooks_returned (a shorter LLM reply is never over-charged); GET /v1/pricing has the exact {base, per_hook} + usd_per_credit. | mode | base | per_hook | count=10 | latency | when to use | |----------|------|----------|----------|--------------|-----------------------------------------------| | instant | 0 | 1 | 10 | sub-second | smoke-test wiring; deterministic templates | | smart | 0 | 2 | 20 | seconds | one LLM call; good hooks, safe synchronous | | research | 10 | 4 | 50 | tens of sec | brief -> draft -> judge; best hooks (job/poll) | Aliases: template|off|quick -> instant; llm|on|fast -> smart; search|deep|deep_research -> research. An UNKNOWN mode is invalid_request. An EXPLICIT smart/research that cannot run returns llm_unavailable (503, retriable), NEVER charged and NEVER downgraded; only `auto` degrades to templates (`llm_fallback:true`, bills instant). Separately, a transient primary-vendor outage may serve smart/research via a configured fallback LLM provider: same envelope, billed at the mode that ran, also flagged `llm_fallback:true` with `llm_fallback_reason: "fallback_provider:..."` naming the provider, so check the reason to tell the two apart. Scores: instant is capped at 50 and `score.source` names the scale ("heuristic" vs "llm_judge", NOT comparable; rubric at GET /v1/scoring). Each hook carries `claim_type` (fact|anecdote|opinion), the model's self-label for verifiable claims. Bought hooks kept 90 days (re-buy restarts the clock; a hook with ANY reported outcome is RETAINED INDEFINITELY), persisted under the ENGINE that ran (judge-degraded research files as "research" but bills smart); list GET /v1/hooks (?mode=&since=), fetch GET /v1/hooks/{hook_id}. ## Response verbosity + wire honesty generate/batch/score/batch/remix take an optional `verbosity` (minimal|standard|full, default standard) shaping ONLY the response; hooks are always PERSISTED in full (GET /v1/hooks/{id} returns the complete score regardless). full = every per-hook dimension score + notes + judge metadata. standard (default) drops per-dimension detail (score keeps {total, source}) + adds `score_disclaimer`. minimal = per hook only {id, text, archetype, score:{total, source}, rank} plus the money fields, no disclaimer. A 10-hook instant envelope is ~2.5/3.1/3.9KB (~610/780/980 tokens); the gap widens at count=25 and for smart/research. Fabricated-stat honesty: instant STAT templates slot placeholder numbers ("94% of ..."), so such a hook carries `contains_placeholder_stat: true` (top-level, kept even in minimal, also on GET /v1/hooks/{id}); the number is invented, replace it with a real one before posting. ## Fleet accounting One human, many agents, one account? Give each sub-agent a SCOPED key (create_key scopes=read|spend|admin + optional daily_credit_cap): a spend-only capped key generates but cannot mint credits, revoke keys, or outspend its daily budget. Tag a campaign per call (`tags` on generate/batch/score/remix/jobs; every event records the key prefix), then filter "who spent what": GET /v1/usage?tag=&key_prefix= (and /v1/usage/export), and GET /v1/hooks?tag=&topic=. ## Report outcomes After posting a hook, report what it did: POST /v1/hooks/{hook_id}/outcomes `{platform, posted_at, views, likes?, retention_pct?, url?}` (or report_outcome). FREE; no view prediction claimed, feeds honest CALIBRATION. platform tiktok|instagram|youtube|x|other; caps 20/hook, 500/day. Guards: views/likes <= 1e11; posted_at not >48h future; url http(s)+host; exact duplicate (hook, platform, posted_at, views) -> 409. GET /v1/hooks/{hook_id} adds `outcomes`+`outcome_summary`; first report on a smart/research hook with views>=100 earns 2 credits back (max 10 rewarded/acct/day; clamped, config can turn it off). First report needs the hook alive (90d); after it the hook is kept forever. Read all your reports in bulk: GET /v1/outcomes (or list_outcomes; filters ?platform=&since=&hook_id=). ## Data retention & deletion Kept: usage/billing telemetry VHGENGINE_EVENT_TTL_DAYS days (default 90); financial events (purchase/refund/adjust/grant) forever; bought hooks 90d (forever once outcomed); reported outcomes forever (anonymized on delete); idempotency + jobs 24h. Leave cleanly: DELETE /v1/account {"confirm":"delete"} (or delete_account, admin scope) is TERMINAL: revokes every key, deletes hooks/webhook/jobs/idempotency, KEEPS the financial ledger, anonymizes your outcomes into the shared cross-customer corpus (still counted in aggregates); a second call 401s. Export first: GET /v1/usage/export?format=json (ledger + hooks + outcomes + email). Full terms + API stability promise (additive changes ship freely; a BREAKING change gets >=90d notice via a `deprecation` field on affected responses + the repo changelog): /v1/legal. ## Wait transparency - expected_wait: every generate response + GET /v1/pricing carry `{mode, p50_ms, p90_ms, source}` (pre-run ETA; "measured" once samples exist, else "default"). - GET /v1/estimates (free): live per-mode p50/p90, sample count, sync-vs-job advice. - progressToken (MCP): pass one on generate_hooks/batch to stream notifications/progress (0..100). - jobs: submit any generate async (POST /v1/hooks/generate/jobs; pass `topics: [1..20]` instead of `topic` for one atomic multi-topic job, result = the batch envelope) -> queue-aware `estimated_seconds` + `poll_after_seconds`; poll GET /v1/jobs/{id}. Charged only when run; collision auto-requeues. A job's stored `result` is always the FULL envelope (verbosity=full) regardless of the sync default. Or set `deadline_ms` (1000-600000) on a sync generate: when the resolved mode p90 exceeds it the call auto-converts to a job (`auto_job:true`) instead of blocking (reuses your Idempotency-Key). - Retry-After: rate_limited (429) and llm_unavailable (503) carry it; honor it. ## Endpoints ``` GET / discovery breadcrumb {service, docs, openapi, mcp, clients, sample, scoring, legal, stats, benchmark} GET /health deep health {status, version, llm_configured, worker_alive, queue_depth, outcomes_reported_total, last_backup_at, backup_count, offsite, integrity} GET /status human status page (paste key client-side; free to load) GET /llms.txt this document GET /openapi.json OpenAPI 3.1 spec (full wire contract) GET /v1/getting-started 5-step quickstart, modes table, wait guidance (free) GET /v1/sample real response envelopes for all 3 modes (free, zero-spend eval) GET /v1/scoring open scoring rubric: weights, ceiling, scales, judge (free) GET /v1/archetypes hook-archetype catalog (free) GET /v1/pricing machine-readable price list + expected_wait (free) GET /v1/estimates measured/expected wait per mode (free) GET /v1/stats public stats: totals, uptime, per-mode p50/p90 (free, no auth) GET /v1/benchmark blind craft-preference bake-off: methodology + any run (free; craft only, NOT views) GET /v1/docs/clients[/{name}] per-client setup guides: list, or one guide as markdown (free) GET /v1/legal terms, data retention, refund policy (free, plain text) POST /v1/accounts signup -> {account_id, api_key, credits, next_steps, docs} GET /v1/account account state (no key echoed) DELETE /v1/account delete this account, TERMINAL {"confirm":"delete"} (admin scope; keeps financial ledger, anonymizes outcomes) GET /v1/account/keys list your api keys (prefixes only); POST mints a new one DELETE /v1/account/keys/{prefix} revoke a key (cannot revoke your last active one) POST /v1/account/webhook register a signed webhook {url} -> secret (once) + a webhook.test ping; GET reads it, DELETE removes it GET /v1/account/webhook/deliveries list deliveries (?status=&limit=&offset=); POST .../{id}/redrive requeues a dead one (admin) GET /v1/usage balance + totals + recent ledger (?request_id= ?tag= ?key_prefix=) GET /v1/usage/export export data (?format=csv|json ?tag= ?key_prefix=); json adds hooks+outcomes+email, csv is ledger-only GET /v1/outcomes list outcomes you reported, newest first (?platform=&since=&hook_id=&limit=&offset=) POST /v1/hooks/generate topic -> ranked scored hooks (base + per_hook by mode) POST /v1/hooks/generate/batch many topics, ONE call, one atomic charge POST /v1/hooks/generate/jobs submit generate as async job (topic OR topics[1..20]) -> 202 {job_id, ...} GET /v1/jobs/{job_id} poll a job {status, stage, progress_pct, eta_seconds, ...} GET /v1/jobs list this account's jobs (limit + offset) POST /v1/hooks/score grade any hook text (1 credit) + rewrite tips POST /v1/hooks/score/batch score many hooks -> ranked + best (1 credit each) POST /v1/hooks/remix rewrite a hook into an archetype (2 credits/variant) GET /v1/hooks list hooks you bought, newest first (?mode=&since=&limit=&offset=) GET /v1/hooks/{hook_id} one bought hook in full, incl. its score + reported outcomes POST /v1/hooks/{hook_id}/outcomes report a posted result (free; views/likes/retention) POST /v1/billing/credits self-serve top-up (max balance 100000) POST /v1/billing/checkout Stripe hosted-checkout link for a credit pack (spend scope; 503 when payments off) POST /v1/billing/adjust admin credit adjustment; negative allowed (only when admin key set) POST /v1/billing/stripe/webhook signed Stripe webhook -> credits on purchase (no auth; Stripe-Signature) GET /v1/billing/events pull billing events (limit + offset) GET /v1/activity what your agents are doing now + recently (0 credits, auth required) GET /v1/activity/stream SSE live feed of in-flight activity (REST-only) POST /mcp MCP streamable-HTTP JSON-RPC endpoint ``` ## MCP Streamable HTTP: POST https://engine-production-3bdc.up.railway.app/mcp (send the Authorization header, or pass api_key per tool when a client UI has no arbitrary-header field). stdio: run `python -m vhgengine.mcp.stdio` with VHGENGINE_API_KEY set (LOCAL subprocess, package installed). SSE framing: MCP frames are CRLF-delimited; the REST feed (GET /v1/activity/stream) uses LF. Read-only docs (llms.txt, getting-started, scoring, pricing, stats, benchmark, legal.md, clients/{name}) are ALSO exposed as MCP RESOURCES, so an MCP-native client reads them without a tool call. 29 tools (core generate/score/remix/account/jobs + webhook ops; webhook registration is REST-only): signup, getting_started, list_archetypes, pricing, get_estimates, health, get_account, get_usage, add_credits, create_checkout, create_key, list_keys, revoke_key, delete_account, generate_hooks, generate_hooks_batch, score_hook, score_hooks_batch, remix_hook, list_hooks, get_hook, report_outcome, list_outcomes, start_generate_job, get_job, list_jobs, get_activity, list_webhook_deliveries, redrive_webhook_delivery. Per-client setup: GET /v1/docs/clients. ## Webhooks Register ONE https webhook (POST /v1/account/webhook {"url":...}) -> a signing `secret` ONCE (GET reads it, never the secret; DELETE removes it). Registration also fires a signed `webhook.test` ping through the NORMAL pipeline (same HMAC + retries), returning its `test_delivery_id`, so you can verify your receiver end-to-end. VHGENGINE POSTs `{event, data, created_at, delivery_id}` for `job.succeeded`/`job.failed`/`credits.low`/`webhook.test` with `X-VHG-Event` + `X-VHG-Signature: sha256=HMAC_SHA256(secret, raw_body)`; failures retry 1m/5m/30m then dead-letter. Inspect: GET /v1/account/webhook/deliveries (or list_webhook_deliveries; ?status=&limit=&offset=; each row = status/attempts/last_status_code-or-error + a 200-char payload_preview, never the full body); redrive a dead one: POST .../{id}/redrive (redrive_webhook_delivery, admin; 409 unless dead). Delivery shares the job worker loop, so a saturated queue lags events by minutes: treat webhooks AT-LEAST-ONCE, not low-latency. Delivered/dead rows (redrivable dead incl.) kept 7 days. URL: https, no creds, a public DNS host; raw IPs (any encoding) + localhost refused. ## Errors Every error is one typed envelope on BOTH surfaces: `{"error": {code, type, message, retriable, retry_after_seconds, details, request_id}}`, and `details.hint` is a one-line next action. Over MCP the tool returns isError=true and the text content IS this JSON. Every response carries `X-Request-Id`. | code | http | retriable | hint | |----------------------|---------|-----------|-----------------------------------------------| | invalid_request | 400/422 | no | fix the fields in details and resend | | unauthorized | 401 | no | sign up / send Authorization: Bearer | | forbidden | 403 | no | use a key granted the required scope (read/spend/admin) | | insufficient_credits | 402 | no | add_credits (or POST /v1/billing/checkout when payments on), or cheaper mode | | not_found | 404 | no | check the id in the path (no existence leak) | | idempotency_conflict | 409 | no | reuse the key with the SAME body | | conflict | 409 | no | a duplicate of a stored record (e.g. same outcome twice) | | llm_unavailable | 503 | yes | retry or use mode=instant (not charged) | | rate_limited | 429 | yes | honor retry_after_seconds / Retry-After | | generation_in_flight | 429 | yes | a generate is already running; retry or queue a job | | server_overloaded | 503 | yes | engine at capacity; retry shortly or queue a job | | too_many_queued_jobs | 429 | yes | wait for a queued job to finish, then submit again | | storage_unavailable | 503 | yes | storage full/read-only; retry shortly | | interrupted | 503 | yes | job hit a restart; resubmit with the same key | | payments_disabled | 503 | no | payments off here; use add_credits (free mode) | | payments_unavailable | 503 | yes | payment provider unreachable; retry shortly | | http_error | varies | 5xx only | check method + path against /openapi.json | | internal | 500 | yes | retry shortly | ## Idempotency Send `Idempotency-Key` on signup and the write endpoints; a replay returns the stored body with `X-Idempotent-Replayed: true` and is never re-charged. Reusing a key with a different body is idempotency_conflict. ## Examples ```bash BASE=https://engine-production-3bdc.up.railway.app # KEY from the signup curl above curl -s -X POST "$BASE/v1/hooks/generate" -H "Authorization: Bearer $KEY" -d '{"topic":"cold plunges","count":5,"mode":"research"}' # add "verbosity":"minimal"/"full"; long runs -> POST /v1/hooks/generate/jobs then poll GET /v1/jobs/{job_id} ```