Appearance
Agent tools & skills — the complete inventory
Tools = read-only data accessors (silent, free to call) — with ONE exception: the three MCP-only signal-board write tools at the bottom of the tools table, which declare surfaces: ['mcp'] and never appear in chat (agent/tools/index.js#onSurface filters both chat's listTools and the MCP bridge's list + dispatch). Skills = state-changing actions riding skill_jobs, with mechanical gates (chatOnly, destructive, requiresCmsCapability, requiresApproval — the last queues as pending_approval behind an Approve/Decline chat card rendered from the skill's preview() hook — plus the item-aware queueGuard(ctx, params) hook: a cheap check against the real target that refuses the queue outright with a plain-sentence skill_skipped reason, e.g. edit_article on a live post whose CMS can't edit live posts). Collected from module manifests by modules/agent/registry.js; the agent core owns the first four tools.
Conventions (the cross-cutting contract, 2026-08-23)
Every tool speaks the same four dialects, so a model chaining ten of them learns each once. The helpers are pure and live in core (core/utils/window.js, core/utils/url-forms.js, core/utils/tool-envelope.js); the audit that motivated them is plans/tool-coverage-audit-2026-08-23.md §7.
| Dimension | The one shape | Where it lives |
|---|---|---|
| Item identity | UUID id where a tool takes one; a full page url accepted as the alternate everywhere — get_item_details resolves url through the spine's page-identity.js#resolveUrl (collection items first, then site pages; the error lists every spelling tried), the pixel tools take url beside path (agent-tools/window.js#pagePath derives the path the browser reports, trailing slash kept), get_performance_history and get_page_health tolerate trailing slash / http→https / www. Lookups against exact stored strings go through urlForms(url) (as given, slash-toggled, https-upgraded); identity JOINS keep using the spine's normalizeUrl. | core/utils/url-forms.js, workspace/page-identity.js |
| Window | range (7d / 28d / 90d, default 28d) OR from+to (ISO days, exact span, previous period = the equally long span before). ONE WINDOW_PARAMS fragment + windowOpts(params); Statistics, Search Console, get_workspace_performance (an exact span sums Orbit's stored daily series — gsc.source 'stored_series') all spread it. Explicitly rolling tools keep days because they return a SERIES whose length is the ask, not a report window: get_performance_history (default 28 since 2026-08-23, was 90), get_ai_referrals. months survives only where it is a true calendar trend (get_backlink_profile); the ai-visibility intel count was renamed refreshes (distinct captures, not months). get_item_details keeps a preset-only range (fixed snapshot windows). | core/utils/window.js |
| List envelope | { total, offset, returned, truncated } on every list-shaped result (listEnvelope). total null = the count failed (unknown, never zero). Replaced hasMore, items_total/items_offset/items_truncated (inventory), total_matching (work history), total_queries_matched (search queries), truncated_ids (read_site_pages → skipped_ids keeps the list). get_backlink_profile.truncation{} stays a per-section envelope, each section carrying the same four keys. list_files computes a real total (file-index.js#listWorkspaceFilesPage) instead of length === limit. | core/utils/tool-envelope.js |
| Empty state | { empty: true, reason, message } (emptyResult) — reason is the cause (never_audited, never_synced, never_scanned, store_not_populated, no_impressions_in_window, capped_this_turn, docs_not_deployed, …), message what to tell the user; payload fields that still apply ride alongside (counts, dates, serp_state). Replaced the { message } / { note } / { synced:false } / bare serp_state:'unavailable_capped' variants in health, ai-visibility, backlinks, gsc, product docs. note remains for caveats on NON-empty data. | core/utils/tool-envelope.js |
| Errors | Tools return { error: '<what> read failed (<cause>). Say the read failed; do not report zeros.' } — never throw. guardRead(label, handler) wraps the handlers that used to throw (get_site_health, get_backlink_profile, the four ai-visibility tools, read_site_pages, get_content_inventory, get_workspace_profile); readError builds the sentence inline. get_product_docs returns error XOR payload (an unknown slug no longer smuggles the index beside the error). Both dispatchers still catch a throw — the wrapper keeps the error's WORDING on the tool's terms. | core/utils/tool-envelope.js |
| MCP | MCP_TOOL_DENYLIST = web_search, fetch_url, get_serp_state, get_keyword_validation (every per-call paid vendor request with no real per-workspace cap on that path) + get_skill_spec. surfaces axis (2026-08-24): a tool may declare surfaces: ['chat'] | ['mcp'], absent = both; onSurface filters chat's listing and the bridge's list AND dispatch. Write tools (readOnlyHint:false) spend a 200/day per-token budget in the bridge. | modules/agent/tools/index.js, modules/mcp/tool-bridge.js |
Tools
| Tool | Owner | What it does | Key inputs |
|---|---|---|---|
| (every turn, not a tool) AUTOMATION STATE | agent core | The chat context block carries context.automation (modules/setup-state.js#getSetupState): scheduled_work on/off + off_because (setup incomplete with setup.missing, or agent paused) + measuring { search_console, pixel } — the same state MCP's list_workspaces returns per workspace. TRUTH tells the model to read it before explaining any missing/stale tool result. Tools stay honest about their own freshness (last_scan, lastSyncedAt, NOT_CONNECTED, gsc_last_snapshot_date); the WHY is workspace state, and this is where it lives | — |
get_workspace_profile | agent core | Full operator profile: identity, ICP, reach, positioning (visibilityTargets, competitors, avgLeadValueCurrency — focusAreas/focusPageIds were retired 2026-08-28: nothing the writer read, the wizard's Push-targets step went with them), brand voice per locale, publishing config, integrations (each collection carries its writer config {type,label,lengthWords,promptNotes}, position, cmsOptionKeys, and — 2026-08-27 — its publishing mode; cms.connected and summary.cms_connected = platform chosen AND credentials complete, the mapping no longer counts), pipeline (onboarding state + the ONE automation composition getSetupState (2026-08-23 — readiness, setup incl. the pixel rung, automation, paused, scheduled_work, off_because, measuring; the duplicated readinessFrom + isAgentPaused composition is gone, so it can never disagree with list_workspaces) + siteContextRun, the latest discovery/enrichment run's status/stage/error), site catalog, uploaded docs (EVERY status since 2026-08-23, with fileName + error — failed uploads were invisible; summary.documents_count counts ready only), media ({count, recent[20]} — the media library), team (2026-08-23 — listWorkspacePeople projected to {name, kind, role}, no emails; the Luniq support identity is excluded since 2026-09-02 — it rides a membership row onto every workspace, so the agent was being told support was a colleague on all of them, and an agent that believes that will suggest handing it work), memory (the agent_memory rows the prompt's WORKSPACE MEMORY block renders — MCP parity; chat already has them in context); identity also carries the workspace row's name/status, experience (workspace/experience.js) and agency {id, name}; audience carries idealClient only (buyerLanguage retired 2026-08-24 — see the settings reference; buyer wording now comes from the uploaded documents). The data-reading section builders live in tools/profile-sections.js (extracted at the 400-line mark), and summary.same_site_workspaces + sameSiteWorkspaces[] (2026-08-05, listSameSiteWorkspaces in the agency spine): other workspaces of the same account publishing to the SAME hostname, each with its pathPrefix. Normally 0; above 0 means this workspace is a SLICE of a shared site, GSC/backlinks/health cover the whole property while inventory/pixel cover the slice, and a "competing" page may live in a sibling the agent cannot read | sections[] |
get_workspace_performance | agent core | Workspace-wide GSC + pixel visitors/conversions/rate + trend; the gsc section reports impressions/clicks/ctr_pct/avg_position each as {current, previous, delta_pct} (REAL previous-period comparison — the old synthetic /4 delta is gone) plus content_type_breakdown (the UI's pie buckets); installation carries gsc_verified + gsc_last_snapshot_date and, since 2026-08-28, pixel_installed_at + pixel_days_since_install (the pixel's first pageview via getPixelInstallStatus, so a lens never reads a fresh install as "traffic switched on"); range is respected for GSC too (the 90d fallback covers articles-only snapshots and says so); conversion_tracking_configured + active_conversion_goals now read the GOALS TABLE, not this window's lead count (2026-08-05 — configured ≠ converting); read failures marked, not zeroed; potential_pipeline_value is GONE (2026-08-20 — avgLeadValue retired, conversion value comes from the per-goal values; get_item_details' pixel section likewise carries currency only) 2026-08-23: takes the shared WINDOW_PARAMS — from+to sums Orbit's stored daily GSC series for the exact span (gsc.source 'stored_series', true previous period; gsc.empty when no days are stored) and the pixel overview via the module's getOverview (the tool no longer calls the RPC itself). | range, attribution |
read_site_pages | pages | Batch reader for site-page BODIES (2026-08-17): up to 40 pages per call, fetched live and parsed by the spine's enrichment fetcher (readPagesText, no AI, no writes) — title, h1, headings, word_count, a 1,500-char body excerpt each. Exists because only key-tier pages store content_text; the corpus-cleanup doctrine requires READING every consequential page before a keep/update verdict, and on the Luniq re-audit 289 of 291 pages had no stored body (fetch_url is capped at 12/turn). Also fixed the same day: getPageBacklinks folds links that point at an OLD redirecting path into the live URL's totals (viaRedirectFrom) — the link-safety gate had reported 0 for linked pages | ids?[], urls?[], excerpt_chars? |
get_knowledge | agent core | Opens knowledge packs from the knowledge library (modules/agent/knowledge/*.md, 2026-08-16 — replaced get_seo_knowledge, which served one 68k-char body whole on every call and could not be opened by part). The CATALOG (every pack's id, when it applies, its terms) rides the always-on system prompt (prompts/index.js → renderKnowledgeFragment, ~1k tokens), so the agent never spends a call discovering what exists: it opens the packs a question touches, several in ONE call, and reasons from them. Declares pin: true — chat.js never elides its results mid-turn (a pack opened at round 1 is still in front of the model at round 20). No input → the catalog (for MCP clients, which do not carry the system prompt). Adding knowledge = dropping a .md with frontmatter (id/title/when/terms/owner/review_every/last_reviewed) into the folder; scripts/check-knowledge.mjs (in verify) checks it, no wiring. 23 packs as of 2026-08-22 (corpus-cleanup split into judgment + corpus-cleanup-execution on 2026-08-17; competitive-intelligence — owner market — added with the Market module: the field, share of voice, head-to-head states, the write/refresh/authority diagnosis, the complete-read rule for removals), all researched to the full skeleton (when it applies · how it works · glossary · playbooks · reading Orbit's data · what good looks like · myths), no citations in the served text: how-to-diagnose, seo-foundations, seo-glossary, search-demand-and-queries, search-performance, content-lifecycle, corpus-cleanup, corpus-cleanup-execution, new-content-planning, writing-standards (drift-tested against writing-doctrine.js), content-types-and-ideas, serp-features-and-ai-answers, ai-visibility, backlinks-and-authority, technical-health, structured-data-and-rich-results, analytics-and-attribution, conversion-rate-optimization, site-architecture-and-pages, multilingual-markets, entity-and-brand, local-seo, competitive-intelligence; the catalog in the prompt shows each pack's id, when it applies and its first 8 terms (~2k cached tokens); the plan, sourcing protocol and decisions are plans/knowledge-library.md | packs?[], pack? |
get_product_docs | agent core | The public product docs, served from the COMMITTED backend snapshot modules/agent/product-docs/ (synced from frontend/src/docs via cd docs && npm run sync; the freshness gate fails on drift, so every backend deploy carries current docs — no cross-tree disk reads). PRODUCT_DOCS_DIR overrides; frontend tree is the dev fallback. No input → index (headings capped at MAX_INDEX_HEADINGS = 24 per doc); doc → one full doc; topic → most relevant sections. The tool description hardcodes the doc-area list — adding a user-doc page means extending that string ("what can you do?" is answered from what-orbit-can-do) | doc?, topic? |
get_skill_spec | agent core | ONE skill's full contract on demand — complete description + exact input_schema (token diet 2026-08-03: the always-on prompt now carries a one-line roster instead of ~7.3k tokens of schemas; the model fetches the spec before queueing) | skill |
get_chat_history | agent core | Past conversations on demand — thread list (titles, recency) or one thread's transcript (last 30 turns, long messages capped). Old chats are NOT in context anymore; a fresh thread starts genuinely fresh. kind (2026-08-23) switches to the workspace-wide machine log in strategy_log — observation (the morning run's summary line with cost + what landed), outcome (each skill job's result line), action — via memory.js#listLogEntries, paged {entries, total, offset, truncated}, limit ≤ 100 | thread_id?, kind?, limit?, offset? |
get_automation_status | agent core | The OPERATIONAL state (2026-08-23, audit §6): automation = getSetupState (the one composition); agent = the level ladder + research_schedule resolved by research-mode.js#resolvedResearchSchedule (the pure rules moved out of daily-run.js into that leaf; the internal dashboard's shapeSchedule copy is gone) + cap; spend_today via core/spend#checkBudget (read-only; unreadable ledger → {error}, never $0); jobs[] = every manifest operatorCrons descriptor that declares job (its job_runs name) joined to latestJobRuns (now one newest-row read PER job — the shared N×5 window let daily jobs crowd a weekly one out) with cron / cronSetting (GSC's per-workspace expression) / lane (measure runs while paused); runs = plan_runs trigger 'cron' via plan-runs.js#listCronPlanRuns (a leaf — daily-run.js imports the tools barrel, so the read cannot live there), events summarised per type, paged. The description states the ledger rule: a gate-refused fire writes NO row, so old/null + scheduled_work 'off' = gated, not broken; skipped is the job body's own reason. The market sync is not listed (its descriptor carries no job yet) | runs_limit?, runs_offset? |
get_collections | collections | (2026-08-23) Every collection with its full schema: archetype (id, item noun, writer skill, required roles + missing_required_roles), writer config, base URLs per locale, CMS wiring (cms_collection, cms_media_collection, cms_options keys), the complete fields[] (key/label/role/type/cms_field/max_length/required/generate/managed/ai_instructions/options_count), per-status item_counts, and the envelope's cms { type, capabilities } (canEditPublished etc.). The read that resolves an item's collection_id and shows an EMPTY collection's expectations. 2026-08-27: publishing { publishes, target, reason, missingRoles } per collection and cms.connected in the envelope — a CMS is optional; a drafts collection is still written into, the reason says why it does not publish | collection?, include_options (option values capped 50/field) |
get_media | collections | (2026-08-23) The media library, newest first, searchable (description/filename) and paginated with total/returned/truncated; rows carry mime_type, file_size_bytes, width/height | search?, limit (≤100), offset |
get_content_inventory | collections | Unified list of blogs OR site pages: identity + indexation (page rows carry indexation.noindex since 2026-08-23 — derived once via deriveNoindex, so the agent never reads a deliberately-noindexed legal page or on-site search result as a not-indexed problem to fix) + GSC + pixel + bands (high-traffic cut is workspace-RELATIVE — p80 of items with any visitors, floor 10 — so bands carry signal on small sites), with coverage rollup. Blog rows carry the REAL stored url/slug + locale_variants[] (each sibling's url); page rows carry locale_variants: [{ id, locale, url }] too since 2026-08-17 — their hreflang siblings, mechanical + symmetric via the spine's locale-pairs.js (absent = unpaired; id: null = declared URL not stored), so a language group is id ∪ locale_variants[].id, never a slug match plus source (always generated since 2026-08-16 — pre-existing site content lives in Pages, reachable via entity_type 'page' / search_site_pages) and the STORED write-time gate quality_score/needs_attention; status filter reaches draft and failed rows (default = the live-ish set) and coverage.status_counts always reports every status's count. Pages widen via tier ('key' default | 'listed' | 'all'), page_type, locale, and every page response carries page_corpus — whole-site facts (latest_refresh since 2026-08-23 = the catalog's newest discovery run incl. status/error/counts, null = never ran — check corpus freshness before trusting it; totals, per-type/per-locale/per-tier counts, indexed, untracked, missing, and since 2026-08-17 search = the site's TOTAL page search traffic from the latest snapshot per page: pages_with_data, clicks_28d, impressions_28d, latest_snapshot_date). Scope honesty (2026-08-17, after the agent told a 341-page site it had "zero pages" without a tool call): an EMPTY blog result carries scope_note with the page-corpus numbers and the exact page call to make; a page result whose scope hides search clicks (default = key tier · catalog types · primary locale — the "what to work on" set, not the site) carries scope_note with shown-vs-total clicks and which dimension to widen (the model reads it before describing how the site performs). sort — 'action' (default: leads → striking-distance → impressions → visitors) or one metric descending ('clicks' | 'impressions' | 'visitors' | 'leads') for "which pages get the traffic". Result is windowed with the shared list envelope total/offset/returned/truncated (page via offset until truncated is false; was items_total/items_truncated before 2026-08-23); view:'list' = lean flat rows (limit up to 1000) for corpus-wide exports/redirect plans; the underlying read pages the WHOLE corpus (no internal row ceiling — a 5,000-article site is fully covered). 2026-08-23 (collections audit): blog rows carry collection_id + collection_key; title = the display title (title role → first text field); status 'live' = published ∪ unpublished_changes; search (title contains) and needs_attention filters, applied at the read so coverage counts the matches; range 7d/28d/90d for the pixel block, whose keys are suffixed by the window (visitors_7d…, pixel.window); sort += ctr/position/date/title/quality with sort_dir (nulls last). 2026-08-23 (§4 pages audit): page path gains tracked (tracked default | untracked | all — the Pages list's hidden set on request, coverage.tracked_filter echoes it), collection (pages attributed to one via collection_id), locale:'all', range for the pixel block (window-suffixed keys like blogs), the list's own columns per row (path, source, discovered_at, last_enriched_at, inbound_links, word_count, collection_id, is_tracked:false), and page_corpus.index_status { indexed, not_indexed, noindex, untracked } — the DERIVED whole-corpus verdict the Pages filter / Performance strip show (via the list_site_pages RPC), beside the raw gsc_indexed_count now labelled raw | entity_type, view, collection, locale (+all), tracked, status (+live), search, needs_attention, range, tier, page_type, band, indexation (indexed|unindexed|noindex — pages are three-state since 2026-08-23; unindexed EXCLUDES noindex, and the rollup gains noindex_count, so the tool partitions the corpus the same way the Pages list does), sort, sort_dir, limit, offset |
get_item_details | collections | Deep dive on ONE item across identity/gsc/pixel/indexation/quality (quality = the LIVE 100-point recompute, blog-only; identity also carries stored_quality — the W-4 write-time gate, which can disagree) plus opt-in content — the FULL body/page text in 30k-char windows (content_offset pages on), the mandated read before any edit_article body edit (identity's excerpt stays capped at 1200 chars). Blogs add the persisted brief (jsonb, truncated past 2000 chars), cms_id, client_approved_at, publish_error ({ action, message, at } — why the last publish failed; null after a clean publish), and gsc's snapshot_90d; page pixel (2026-08-23) carries the FULL audience block the detail UI shows — avg_time_on_page_sec, avg_scroll_pct (null = unmeasured, never zero), traffic_sources[], ai_tools[] — via the targeted pixel_page_detail RPC (same humanity gate as pixel_pages_summary, verified field-identical where they overlap; also cheaper — one path instead of scanning every path's summary row); page identity adds heading_outline, schema_types, og_keys, inbound_links, viewport/lang, is_tracked, tier, key_score, collection_id, missing_since, last_seen_at, sitemap_lastmod. indexation.noindex (pages only; null for blogs, which carry neither meta_robots nor gsc_indexing_state) is the page's OWN directive, derived from a noindex/none token in meta_robots OR gsc_indexing_state BLOCKED_BY_META_TAG/BLOCKED_BY_HTTP_HEADER — the same deriveNoindex the Pages list chip uses, so tool and UI cannot disagree on whether a page is broken or deliberate. rich_results is pages-only (blogs have no such column — emitted null with a caveat, no longer a fake []). 2026-08-23: collection-item identity adds image_url, content_version, updated_at, first_published_at, collection_key/collection_name/archetype, custom_fields_unmapped, locale_variants[].title; a top-level cms { type, capabilities } block; quality is archetype-aware ({ not_applicable: true, archetype } for non-article collections instead of a meaningless article score); new media section (in the default set) resolving each media field to the library row (library: null = stock/CMS-hosted). Vocabulary says "collection item" — entity_type 'blog' is any item Orbit wrote, whatever its collection. 2026-08-23 (§4 pages audit): page gsc gains snapshot_90d (B6 — the stale "no 90d" text is gone; pre-migration rows carry snapshot_90d_note); page indexation gains the crawl forensics crawled_as / page_fetch_state (the concrete "Google could not fetch it" cause) / robots_txt_state, and referring_urls / in_sitemaps carry *_total + *_truncated for their 5/3 caps; new opt-in links section (pages only) = inbound internal link EDGES from site_page_links via the spine's getInboundLinks (inbound[] with source path/url/title, inbound_total, inbound_truncated at 50). Page fetchers extracted to item-details-page.js Identity by URL (2026-08-23): url is accepted as the alternate to id — resolved via the spine's resolveUrl (collection items first, then site pages; trailing slash toggled, http→https), entity_type then comes from the hit and the response carries resolved_from_url; a miss lists every spelling tried. | entity_type, id, sections[] (+media, +links), range, content_offset |
get_content_clusters | collections | Cannibalization clusters with keeper/consolidate tags over live PRIMARY rows; the row cap is genuinely PER-LOCALE and truncation is flagged. 2026-08-23: collection scope, and excluded_no_keyword counts the rows without a target keyword that used to be silently dropped (a non-SEO collection read as "clean") | locale?, collection? |
get_keyword_validation | collections | 1–100 keywords through Orbit's keyword index: volume, KD, CPC, intent (~$0.05/batch) | keywords[], language |
get_serp_state | collections | Live SERP for ONE query: top-10, PAA, related, snippet, AI Overview + cited domains. Cap 3/turn | query, language |
get_publish_outcomes | collections | The corpus bucketed by time SINCE PUBLICATION (settling/14d/30d/60d+ cohorts, landed rates, per-piece detail) — the only window anchored on when a piece went live; null anchors are excluded and counted, never bucketed. Piece rows carry the stable key id (feed to get_item_details/edit_article) + the REAL stored url alongside title/locale/days_live/cohort — a row used to be titles-only and therefore unreferenceable. 2026-08-23: collection scope, offset, and a total/returned/truncated envelope (the 100-row cap used to be silent); rows carry collection_id | collection?, limit, offset |
get_backlink_profile | backlinks | Domain rank, referring domains, churn, spam, trend (months 1–24, default 12), brokenTargets (since 2026-08-22: the complete named list of linked URLs the crawler saw fail, http_status ≥ 400 — the authoritative broken-pages read; summary.brokenPages is the vendor's historical counter and is never to be reported without named URLs), linked pages on the domain (top_pages_limit 1–200 — the full stored set the UI shows; rows carry firstSeen/checkedAt, and since 2026-08-16 pageState live/redirect/gone/unknown + httpStatus + redirectUrl — retired URLs that still earn links are rows, a redirect row deep-links to where it lands, a gone row is the redirect-it-to-keep-the-links worklist); optional URL link-safety check — with include_url_referrers each passed URL returns its actual referrers {domain, domainRank, urlFrom, anchor, dofollow, firstSeen} ("who links to /pricing"); include_link_losses returns every page whose referring-domain count dropped since the previous sync (the recovery worklist); referrers_limit honors any 1–100 (≤10 no longer ignored) and widens topReferrers to the full stored set with anchors + dofollow — the anchor-audit read. Every embedded list now ships its denominator in truncation (topPages/topReferrers/pages/linkLosses → {total, returned, truncated}, totals from getBacklinkListTotals's two head counts; total: null = the count read failed, unknown not zero) — a capped list can no longer read as the whole profile, and urls past the 50 cap shows up as truncation.pages.truncated | urls[]? (≤50), referrers_limit?, top_pages_limit?, months?, include_link_losses?, include_url_referrers? |
get_site_health | health | Score (+delta), Google + AI verdicts, category scores (with labels), AI-bot access, ranked issues (each with why + siteLevel); the audit block is degradation-honest: running {status, stage, pagesCrawled, trigger, startedAt}, lastFailure {error, failedAt}, crawlError, crawlCoverage, cwv (source:'field' = graded on real-user CrUX data; source:'lab' = no real-user data, so nothing is graded and lab {lcp, cls, tbt, perfScore} is a homepage diagnostic only — since 2026-08-19 a lab run never yields cwv-poor), llmsTxt, tls {validTo, daysToExpiry}, sitemap {present, urlCount}, robots {present} — a partial/failed crawl is visible as such, never asserted as full-site health. severity:'all' adds notice-level findings (10 checks the default hides), include_muted includes muted groups (flagged muted:true), include_history adds ≤52 {finishedAt, score} trend points | severity?, include_muted?, include_history? |
get_page_health | health | Every OPEN issue on ONE URL with evidence (source + cause + location) plus the catalog's surface/why/fix; URL filtering is DB-side now (no more false-clean past 2000 open issues); include_resolved adds FIXED issues (resolved_at set — the "was this fixed" answer); empty = "no known issues", never "perfect" URL forms tolerated (scheme / www / trailing slash) — matched on url_normalized. | url, include_resolved? |
get_health_issue_urls | health | FULL affected-URL list for one check (site view samples only 3) incl. linkedFrom referrers; offset pages through all 500 stored occurrences; include_resolved adds fixed occurrences (each carries resolvedAt) | check_id, limit, offset?, include_resolved? |
get_ai_visibility | ai-visibility | Snapshot/previous/trend, per-surface rates (chatgpt/gemini/google_aio/ai_mode), competitors, top domains; leads with a scan {running, last_scan} block so mid-scan emptiness reads as "results pending", never "no presence"; include_presence_history adds the ~70-day per prompt × surface {run_at, mentioned, cited} series ("when did we lose this mention") | include_presence_history? |
get_ai_prompt_results | ai-visibility | Raw stored AI answers (excerpt ≤1200 chars — UI parity — with excerpt_truncated flagged, sources ≤20); since/until bound run_at to one scan or month; limit up to 200 + offset (one full scan = 100 rows) | prompt_id, surface, mentioned, cited, since, until, limit, offset |
get_ai_prompts | ai-visibility | The tracked prompt set (≤25 active) | — |
get_ai_prompt_opportunities | ai-visibility | Stored category intel: most-cited domains/pages + untracked real questions + tracked-set gaps; months (1–6) adds intel_history — older intel refreshes for "which domains newly entered the category answers" months → refreshes (2026-08-23): it counts distinct intel captures. | months? |
get_search_queries | gsc | EVERY query Google reported for the window, from the stored series (gsc_query_daily via listSiteQueries — no top-N cap; total_queries_in_period is the real count): per query clicks/impressions/ctr_pct/impression-weighted position + previous-period deltas and is_new, days_seen+first_seen (a spike and a trend can carry identical totals), branded (mechanical, name+domain, punctuation-squashed), near-ranking flags; query (exact text) returns the per-page breakdown with titles resolved through the spine; min_pages_ranking: 2 = the whole self-competition audit in one call; branded: 'only'|'exclude' and min_impressions filter; source:'live' = pre-backfill top-500 fallback, labelled; read failures marked, never zeroed. 2026-08-23 (§4 audit): flag = the query table's chips (rising/declining/new/near/leak/cannibalisation, thresholds defined ONCE in gsc/query-flags.js, duplicated client-side), sort + sort_dir (nulls last, position asc by default), last_seen per row, new_queries_count in the envelope, explicit from+to beside range (previous period = the same length before; range:'custom'). Lives in gsc/agent-tools/search-queries.js | range (7d/28d/90d), from+to, query?, query_contains, flag?, position_min/max, min_impressions?, branded?, min_pages_ranking?, sort, sort_dir, limit, offset |
get_performance_history | gsc | The DAILY series, three scopes: query given → that query's trajectory (up to 480 days — the outcome-loop read: "did the work move it?"); url given → one article/site page (7d+28d+90d per day since 2026-08-23 — B6 fixed, page rows before the 90d migration read 0; 180-day retention; include_queries adds each day's stored top queries); both omitted → the SITE-WIDE series from gsc_daily_totals (480 days, zero quota, compare_previous returns the preceding period as its own series with any shortfall named), or since 2026-08-23 ONE dimension member's series via dimension + value (surface/country/device — "when did Discover die"). Every response carries history_from/data_through — a short series is tracking depth, never site age 2026-08-23: default days is 28 (was 90) — kept as days, not range/from+to, because the result is a series whose length is the ask; the per-URL lookup goes through urlForms (trailing slash / https tolerant). | query?, url?, days? (≤480 site/query, ≤180 url), dimension+value?, compare_previous?, include_queries? |
get_search_breakdown | gsc | Search traffic split by a NON-QUERY dimension: surface (web/discover/image/video/news — Discover appears nowhere else), country (demand-side, alpha-3), device, appearance (rows OVERLAP — shares are of the appearance sum), branded (branded/non_branded + the anonymized remainder Google withholds, surfaced as its own row), content_type (from the page catalog, not URL patterns). Rows carry clicks/impressions/ctr/position/share_pct + previous-period deltas; notes[] carries each dimension's caveats verbatim. 2026-08-23: explicit from+to beside range (content_type serves the nearest preset and says so). Lives in gsc/agent-tools/search-breakdown.js | dimension (required), range, from+to, limit, offset |
get_work_history | agent core | The completed-work ledger (skill_jobs): every dispatched action with honest outcome — status, result essentials, error, rationale. Reads through modules/agent/work-history.js#listSkillJobs (2026-08-23; the tool no longer queries supabase itself). signal_id / storyline_id filter to one card's or narrative's work trail ("what did Orbit run for this signal"); the envelope is the shared { total, offset, returned, truncated } (exact count). outcome only carries the six article-job keys and is empty for every non-article skill — the description says so | status, skill, signal_id, storyline_id, since, limit, offset |
search_documents | agent core | Full-text inside uploaded documents: snippet search (the operator's OWN wording) or one doc's text capped at 12k chars | query | doc_id, category |
get_storylines | agent core | The Signal Dashboard's diagnosed narratives with their play, impact, effort, and signal_ids evidence trail. STATUSLESS since S6b — a storyline is a grouping; the member signals carry the board statuses. "What did the work earn" lives on the MEMBER signals since 2026-08-20 (their outcome_latest, via get_signals); the description routes the model there (the stale paragraph that read as if the storyline row carried outcome_latest was rewritten 2026-08-23, B5). Own file since 2026-08-23 (tools/get-storylines.js). Since the quarterly verdict (2026-09-01, step 7) rows carry outcome_result — { state: 'under_90d', since_days } while the oldest claimed member is younger than 90 days, else `{ state: 'measured', verdict working | flat |
get_signals | agent core | The raw evidence atoms behind the dashboard — mechanical detectors plus the morning analysts — with measured numbers, first_seen/last_seen (times_seen was dropped 2026-08-16) and group_key (the group each was clustered into, storyline:<id>; replaced storyline_id in S6b). Rows carry status, how (the prefilled one-click action, when any) and claimed_at (the last human claim); the status enum mirrors ALL_STATUSES (the seven board stages plus gone — Orbit's own close of an open row it stopped finding, off every tab and never Done; the description states the ownership rule: open is Orbit's, every other status is the person's). category filters IN the query and total is the real DB count for the filter (count: 'exact'), never the window size — page past limit with offset when truncated is true. Closed rows carry closed_by (the CLOSERS registry: human / chat — a close the agent proposed and a person approved, 2026-08-23 — / absence / covered / verified_gone / restated_no_more / superseded / page_removed; null = pre-attribution) + closed_at; a machine close is never presented as work a person completed. language labels the card copy's language (S14 subject-language cards: copy about non-English content is stored translated; technical rows stay 'en'). Since 2026-08-14 rows also carry body_md — the card's markdown document (what's happening, why it matters, the proposed plan, the person's notes) — alongside body_edited_at: set means a human owns that text (editing claims the card), so quote it and never contradict it as if it were Orbit's own wording; (the 2026-08-16 body_proposed proposal flow was retired 2026-08-22 — a claimed card is frozen, the column is dead); since 2026-08-20 rows carry outcome_latest — the newest reading per KPI ({ value, baseline, delta }, nightly, results-since-claim: page funnel + query family + pinned-keyword positions + leads that TOUCHED the pages and their value) — replacing the retired 14/30/90 outcome_checks; the envelope carries last_runs (the last sweep + research run: what ran, what could not, what moved). 2026-08-23 (coverage audit §1): every filter lives IN the query via board/list-filters.js#applySignalFilters — ids (direct read), storyline_id (group_key = 'storyline:<id>'), resource_id, page_url (resource_id OR any linked_pages[].url, a PostgREST or with a quoted cs JSON value), source_module, goal_lens (the ONE backend derivation board/lens.js#lensFor/lensOrFilter — category → source module → foundation; the frontend's board-item.ts#lensFor duplicates it, change both), severity[], since + since_on (first_seen default / last_seen). sort = last_seen (default) / first_seen (database-ordered) or severity / value (the board's order: severity then oldest first) / effort — rank sorts read a SORT_WINDOW (2000) and order in memory, disclosed in the description; dir flips. include: ['readings','jobs'] attaches outcomes.js#listOutcomeReadings (the daily ledger, ≤ 5000 rows + total/truncated) and listSignalJobs (last 10) per row, honoured only on ≤ 10 rows (include_skipped explains otherwise). include_summary adds boardOverview minus top: severity counts + claimed (live rows), per-stage counts over every board status, new_48h, looks_fixed (claimed + evidence.evidence_gone_since), earned (per-KPI delta sums, pos: KPIs excluded — per-card sum, not the deduped rollup). Rows now also carry fingerprint, last_verified_at, group_set_by; the description names every closer (human / absence / covered / verified_gone / restated_no_more / superseded / page_removed), the real default status set (open + the four claimed stages), the evidence keys (latest, readings, prompts, folded, evidence_gone_since, closed_reason, linked_pages_total, reopened, pages_removed/pages_moved, unknown_cause), and what detail / measure hold. Since 2026-08-25 every row also carries linked_signals (visible entries of the other cards it is about: `{ id, relation same | related, by, at }) and linked_keywords(the searches it tracks, as strings) andlinked_prompts(since 2026-08-28: the AI-search questions it tracks, as strings; presence read live throughget_ai_visibility) plus linked_keywords_window (28d): numbers for the keywords are read live through get_search_queries, never stored on the card. create_signal/edit_signal/ the chat skillupdate_signalaccept both lists (whole-list replace,readLinkedLists shared); an Orbit-added entry left out stays excluded (board/linked-lists.js). Since 2026-09-01 (signal plan step 0) the board is FIVE stages (the client stages retired) and rows carry assignee(a workspace member's user id, null = unassigned; people-only — Orbit never assigns) plus best-effortassignee_nameresolved once per read fromlistWorkspacePeople; the assigneeFILTER takes a member's user id or email (resolved server-side) or'none'for unassigned. Since step 1 (same day) rows also carrysubject— what the card is about, as identity (page:url / locale:code / cluster:pillar / site:*; null = keyword-only or a person's unlinked card; derived only byboard/subjects.js#deriveSubject) — and evidence may carry diagnosis[]: analyst findings attached to this card's subject (lens, facet, title, detail) instead of minted as siblings. Since step 2 rows carry lift—, computed only by board/lift.js, never typed and never presented as a forecast; the valuesort ranks bylift.score(severity tiebreak). ThesignalRowprojection is exported forget_storylines` |
search_site_pages | pages | Two-pass search over the ENTIRE site catalog: the relevance-ranked FTS RPC first, then a loose ilike (title/url) fallback fills the remainder — the fallback ALSO reaches collection-attributed, untracked and sitemap-missing pages the RPC structurally can't; rows carry id (feed to get_item_details), key_score, match_type (fulltext|substring), and is_tracked:false/missing_since flags on dead/untracked matches — "do we have a page about X" in one call. Matches are collected to a 200-row ceiling and THEN windowed, so the result carries total (matches found), offset, returned and truncated (more matches beyond this window — page with offset), plus scan_truncated when the 200-row ceiling itself was hit (total is a floor then). 2026-08-23: types[] accepts ANY stored type string (page_type is free-form; the known list is examples in the description) | query, types[]? (any string, ≤20), locale?, limit (≤50), offset |
get_conversion_picture | orbit-pixel | content_contribution (2026-08-05): the portfolio rollup — share of pageviews/visitors/leads the whole published corpus touches, generous on lead attribution so a near-zero share is conclusive; tracking.configured reads the goal definitions, not this window's leads. Plus: Per-page visitors and leads, traffic sources, AI-tool referrals, trend (7d/28d/90d) — leading with a tracking block that says whether conversions are being counted at all: installed_at/days_since_install/report_ready/total_events_24h, FULL goal definitions (active/is_conversion/dedup/match/value_amount/count_28d/value_28d/last_fired_at), and the candidates[] worklist (+candidates_total) — so an unset goal is never reported as a failing page. Both source views ride along (traffic_sources/ai_tools by visitor + _pageview variants); page rows carry real titles, real non-article lead counts, ai_share_pct, visitors_7d/visitors_prev_7d; article_id drills into one article's full stats + recent leads, path drills into ANY page (visitors, pageviews, leads, time, scroll, sources, AI tools, devices, browsers; refuses past 10k pageviews). Per goal count_in_window/value_in_window follow the window; count_28d is always rolling 28d | range | from+to, limit, attribution? (first/last/linear), article_id?, path? |
get_lead_journeys | orbit-pixel | Every lead with FULL multi-touch attribution: first/last/all touched articles (titles hydrated), session source/medium/campaign, landing path, first-touch AI tool, value_amount, origin (live/backfill) — the read behind "which content influenced our leads / what is this cluster worth"; status defaults to counted (real conversions) — candidate/ignored/all reach the unclaimed submissions for tracking review. The window is honest: total is the exact matching-row count for range + status (count: 'exact' on the same query), with offset/returned/truncated so a 50-row page is never summed as the period's total; leads carry id/visitorId/sessionId — the join to get_journey_sessions | range | from+to, status?, goal_key?, limit (≤200), offset |
get_journey_sessions | orbit-pixel | Visitor sessions as ordered page paths (time-on-page, scroll, outcome converted/engaged/bounced, lead details on converted) via pixel_journey_sessions; article_id narrows to sessions that touched one article — the funnel read (no path filter: the RPC filters by article only) | range | from+to, outcome?, article_id?, limit (≤100), offset |
get_ai_referrals | orbit-pixel | AI-referred human visitors (current vs prior window), per-tool visitors + AI-first-touch leads, recent AI leads, assistant-linked landing pages (observed citations — pixel-but-not-scan = prompt-curation candidates). Floor caveat + no lead↔prompt causality pinned in the description; days is a ROLLING UTC span, said plainly; caps disclosed with totals/truncated flags | days? (7–90, rolling UTC), landing_pages_limit? (≤100), recent_leads_limit? (≤50) |
get_pixel_status | orbit-pixel | Call before explaining any zero. Three states, not two: lastSeen null = the script has NEVER reported (numbers are ABSENT, not low); installed false with a lastSeen = it reported and STOPPED, so there is real history and a real problem after that date; otherwise normal. firstSeenDate is the first day with data — compare it to the window before calling anything a trend, because a 28d read on a 20-day-old install covers 8 days nobody measured | none |
get_realtime_activity | orbit-pixel | The last 5–180 minutes off RAW pageviews — the only read that skips the rollup and the session table, both of which lag by exactly this interval. visitors is the roster: one entry per person per visit with current page, a trail of up to 3 pages before it, pageviews and firstAt/lastAt (capped 40, with visitorsTotal/visitorsTruncated) — describe people from THIS, never by counting feed rows. visitorsLast5 is the honest "on the site now"; visitorsNow spans the WHOLE window despite its name. byLane is REQUESTS (one crawler is not one visitor); a dense per-minute strip where a quiet minute is a zero not a gap; top pages, sources, and — for an empty window — lastVisit + last24h, which tell a quiet minute from a dead site. lane keeps one lane in visitors/feed — applied AFTER the RPC's caps, stated in the result's laneFilter | minutes (5–180), lane? |
get_traffic_timeseries | orbit-pixel | The report surface as one tool — any metric (sessions/visitors/pageviews/conversions/value/bounce_rate/conversion_rate/engaged_time) by any dimension (source/country/device/browser/campaign/ai_tool/agent/landing) over any window, in two views: trend (daily series over pixel_daily) or table (the period ranked, with a per-row delta vs the previous period). Windows take a preset OR from+to for an exact calendar span. Three things the description tells the model to read correctly: additive:false means the series must NOT be summed (distinct visitors overlap across dimension values; a ratio is not a sum); coverage.kind distinguishes a measurement gap (universal) from the finding itself (subset — '(unknown)' on ai_tool means "not AI-referred", not broken tracking); partialDay is today, real but incomplete. (other) and (unknown) are real rows, so the parts always total. Table rows re-rank by metric only for conversions/value/pageviews (others rank by sessions). breakdown: 'goal' (table only) = counted leads per conversion goal for the exact window incl. zero-count goals and the (unassigned) row, previous under compare — the Conversions tab | metric, breakdown (+ goal), lane, view (trend/table), range or from+to, filter_dim+filter_values, resolve_pages, compare, limit |
get_session_report | orbit-pixel | Visit-level aggregates ACROSS all sessions via pixel_session_report: totals (bounce rate, avg engaged time, conversion rate/value), landing pages with their own bounce + conversions, exit pages, and traffic by session-scoped source. Complements get_journey_sessions, which inspects one visit at a time. Bounce = no pageview cleared the engagement bar AND nothing converted (a generated column, so no query can invent a second definition) — deliberately not "one pageview". Takes the full window — explicit from/to used to be dropped (audit B1) | range | from+to, lane?, limit (≤100) |
get_traffic_lanes | orbit-pixel | Humans vs AI & agents vs bots per day via pixel_lane_trend. Absorbs the old get_crawler_activity: byAgent is which crawlers read the site and how much, plus every agent seen with how its claim was verified (ip_range/rdns/signature/edge). A UA is a CLAIM — a failed check yields junk, keeping the claim visible. Googlebot sits in bots with feedsAi set (same crawl feeds AI Overviews; Search Console tells that story). ⚠️ Coverage: only agents that execute JS are visible — GPTBot/ClaudeBot are structurally undercounted, and the tool says so. path/article_id add agent_pages: which crawlers fetched THAT page (incl. the stealth_scraper bucket) beside the site-wide crawler table | range | from+to, path?, article_id? |
get_engagement_events | orbit-pixel | Click rollups per page (internal/outbound/action × target, clicks + sessions), form starts vs converted sessions, article continuation + deep-read share — the micro-behavior evidence stream (never conversions). path scopes clicks + forms to one page; per-kind and top-article caps are params with totals/truncated flags | range | from+to, path?, per_kind? (≤100), top_articles? (≤50) |
fetch_url | web | One page → readable text (12k chars), SSRF-guarded, cap 12/turn | url |
web_search | web | Live Google organic + related searches, market-localized (~$0.05, cap 5/turn) | query, language? |
create_signal | agent core | MCP-only write (surfaces: ['mcp'], never in chat — there the verb is the approval-gated update_signal skill). A connected assistant puts its own card on the board as the person: thin wrapper over signals.js#createSignal (born owned, manual/manual, never into a closed status); sections build the document via replaceSections — "Notes" refused at the schema level. Returns the created row (signalRow projection). assignee? (2026-09-01): hand the card to a workspace member from birth — user id or email, resolved via resolveAssignee | title (req), sections?, severity?, effort?, goal_lens?, assignee?, linked_pages?, status? (CREATE_STATUSES, default planned) |
edit_signal | agent core | MCP-only write. ONE card: fields (saveSignalEdits), document sections (replaceSections, Notes never), and/or the stage — any human-legal status, stage move FIRST then edits (the board PATCH's order, so reopen + edit is one call). Closes land under the mcp closer (person-class, final — "closed by a connected assistant"), optional reason ≤300 → evidence.closed_reason; the response states when an edit claimed an open card to planned. assignee? (2026-09-01, user id or email, null unassigns) lands through setSignalAssignee and deliberately does NOT claim. Budgeted 200 writes/day/token in the bridge | signal_id (req), title?, sections?, severity?, effort?, goal_lens?, assignee?, linked_pages?, status?, reason? |
edit_storyline | agent core | MCP-only write. The update_board skill's actions without the approval hop (the connection IS the person): create / update (never alters ## Notes; play steps stripped to {text} — no how from this surface) / add_members / remove_member / disband (members stay) / dismiss (dismissNarrative, closer mcp — every member closes as Dismissed + the group memory is written). Returns the resulting storyline row or freed/dismissed counts | action (req), storyline_id?, headline?, diagnosis?, play?, body_md? (≤40k), goal_lens?, severity?, effort?, impact_estimate?, impact_unit?, signal_ids?, signal_id? |
Plan events (emit_plan, handled in applyPlanEvents): queue_action, save_memory/update_memory/forget_memory, log_observation, plus the autopilot-only topic_idea and storyline. (The goal events died with S13 — goals are retired; suggest died with the suggestions system.)
Synthetic output channels defined in chat.js (not registry tools): emit_plan (required final call), render_chart (one data-first visualization/turn — the model sends labels+values, deliverables/chart.js draws it), create_file (≤3 files/turn, one complete call each — the output-budget ladder replaced the old parts protocol), export_table (row deliverables joined server-side from a tool result by source_id), run_transform (sandboxed pure-function compute over this turn's tool results).
Skills
| Skill | Owner | What it does | Flags / est. cost |
|---|---|---|---|
write_article | collections | ONE article, all locales, lands as reviewable draft — never publishes. The brief gate runs first: duplicate targets and bare definitional TOFU reject (optional tofu_justification input overrides); ROI-shaped briefs without data get their angle reframed to decision criteria; the workspace's saved standing instructions steer the derived angle and question, and reject a topic they forbid outright. Directives arrive on the preflight CONTEXT (ctx.directives), never as a param — they are not the planner's to propose or amend — and are stamped on the brief so the writer honours them. The manual dialog's preview passes its gated brief through so the write reuses it (internal input); optional customer_question anchors the answer-first lead (derived by the brief builder when absent); exports a preflight(ctx, params) contract hook (module-owned brief gate, used by the autopilot draft ladder so rejections become observations pre-queue); brief decisions surface as job observations; refuses landing_page collections | $0.60 |
write_landing_page | collections | Switched off 2026-08-27 — the landing_page archetype is status: 'off', so the manifest does not register this skill. When on: ONE conversion landing page, all locales, draft | $0.40 |
edit_article | collections | Field-driven edit of ONE item; forgiving key resolution + body_patch {find, replace} for ONE surgical edit or body_patches [{find, replace}…] (max 20, applied in order, all-or-nothing) for a change at several spots — one action, one approval card, never a full-body resend (exact-unique, whitespace-tolerant); body edits start with a full get_item_details sections:['content'] read; locale rule: one row = one language version, edit the row matching the user's language unless another is named; result carries changes (per-field before→after) and, when sibling language versions exist, a followup offering sync_locales in the ✅ line (the versions just went out of sync) | chatOnly + requiresApproval, $0 |
sync_locales | collections | Bring an item's OTHER language versions in line with one just-edited version — thin orchestration over the canonical locale-sync service (same as the editor's Sync-languages button): each sibling re-adapted natively (own keyword/angle/slug/links preserved), quality-gated; published siblings only with canEditPublished, discovered siblings never; per-locale synced/skipped/failed outcomes; the follow-up the agent proposes after an edit_article on a multi-language item (preferred over per-language edits when several locales exist) | chatOnly + requiresApproval, ~$0.30 |
publish_article | collections | Publish/republish an article group to the CMS + Google indexing ping | chatOnly + requiresApproval, $0 |
delete_article | collections | Delete article + all locale siblings (CMS-side per capability) | destructive + requiresApproval + requiresCmsCapability: canDeleteFromCms — filtered out everywhere while the capability is false fleet-wide (v1 doctrine 2026-08-14), $0 |
edit_ai_prompts | ai-visibility | Curate the tracked prompt set: add / edit (reword) / deactivate (retire, history kept) / remove (hard delete incl. stored answers), ≤15 | chatOnly |
get_competitor_landscape | market | The stored competitive field, six views, ONE MARKET at a time (market param, key <locationCode>-<langCode>; markets lists the monitored set with each one's stance — established vs entering, where a 0% share of voice is the starting line, not a failure): scorecard (per-domain standings + deltas + SoV trend + the tracked/watch/suggested set with overlap evidence), gaps (the battle plan — priority = value × winnability, diagnosed write/refresh/authority), movement (won/overtaken crossings), keywords (the raw head-to-head table, state/competitor/min_volume filters), content (the rival move log with per-domain sitemap provenance — atlasComplete: false suppresses removal claims), atlas (rival site structure + section velocity). Zero API cost; every list enveloped {rows, total, truncated}; AI-answer share deliberately composed from get_ai_visibility, never duplicated | view?, competitor?, state?, min_volume?, days?, limit?, offset? |
edit_markets | market | Which markets the workspace monitors (country × language, max 5): add, remove, set the primary. The set auto-builds from service areas × published languages; this is for the markets being scaled into. Monitoring a market with no rankings is deliberate — that is what entering one looks like, and it is what produces the beachhead/transfer cards | chatOnly |
edit_competitors | market | Curate the competitor set: track (full weekly sync, ≤5 slots/locale) / watch (free site-atlas monitoring, ≤15) / remove (history kept). The one write path — keeps the profile's competitor name list in step automatically | chatOnly |
check_ai_answer | ai-visibility | Live-probe ONE prompt on one surface (chatgpt default / gemini / google_aio / ai_mode), store the run | $0.005 |
Presentation (H-4): every skill declares its own label (human name for prompt/prose) and a tool may declare summarize(result) (one-line activity-feed summary) — both contract fields, no core edits per module.
Gates (skills/contract.js + runner.js): chatOnly/destructive run only when triggerKind === 'chat'; requiresCmsCapability filtered from listings, refused at queue, re-checked at dispatch; required params validated before insert; requiresApproval queues as pending_approval — never dispatched until a workspace member approves the chat card (POST /jobs/:id/approve|decline, audit-stamped reviewed_by/reviewed_at, 7-day expiry to skipped).