Skip to content

[Feature]: Kickoff prompt — one user-owned project-planning preamble that prefills the composer visibly, is editable in Settings, and that models can propose edits to under an explicit opt-in #46

Description

@radroid

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I am describing a concrete problem or use case, not just a vague idea.

Area

apps/web

Problem or use case

Starting a new project is the one moment T3 Code has the least reuse to offer, and the workarounds that exist are each scoped to something that makes them not-quite-work.

I keep re-typing (or re-pasting) the same "help me plan this app idea" preamble at the start of every new thread. What I want is one prompt I own, that shows up at the new-project moment, that I can read and edit in Settings from any client, and that gets better over time because the model that just used it can suggest an improvement.

T3 already ships three overlapping pieces of this, and none of them is the whole thing. Being precise about what already works matters, because the honest version of this request is narrow.

What already works (do not rebuild these):

  1. Claude custom slash commands — zero code, today. ClaudeAdapter sets the claude_code preset and all three setting sources (apps/server/src/provider/Layers/ClaudeAdapter.ts:3528-3529, with CLAUDE_SETTING_SOURCES at :884), so the CLI inherits ~/.claude/commands/*.md, CLAUDE.md, skills, agents and hooks. ClaudeProvider then harvests init.commands into the composer's / menu (apps/server/src/provider/Layers/ClaudeProvider.ts:747), consumed at apps/web/src/components/chat/ChatComposer.tsx:1099-1110. Selecting one inserts the literal /<name> (ChatComposer.tsx:1728) and the CLI expands it. So ~/.claude/commands/kickoff.md genuinely is a working, autocompleted kickoff prompt — I already have one such file (~/.claude/commands/animate.md), so I know the shape works.
  2. A Settings-editable prompt precedent. sourceControlWritingStyle.customInstructions is a Settings textarea whose text is fed to a model (packages/contracts/src/settings.ts:421-429, UI apps/web/src/components/settings/SourceControlWritingSettings.tsx:118-136, consumed apps/server/src/git/GitManager.ts:612-615).
  3. A reusable-prompt-into-composer surface. The prompt stash: Cmd+S, 20 entries, t3code:prompt-stash:v2 (apps/web/src/promptStashStore.ts:7,:17).

The four gaps those leave, each with evidence:

  • Cross-provider. Slash commands are Claude-only. command grep -rn "slashCommands" apps/server/src/provider/Layers/ returns producers only in ClaudeProvider.ts (:747, :904-935). CodexProvider surfaces skills (client.request("skills/list") at CodexProvider.ts:394) but never slashCommands; CursorProvider/GrokProvider/OpenCodeProvider have neither. A Codex, Cursor, Grok or OpenCode user has no equivalent at all.
  • Remote-client reach. ~/.claude/commands is a directory on the host filesystem. A phone or a browser paired to a remote T3 server can neither see nor edit it.
  • Transcript honesty. T3 sends /kickoff … verbatim and the CLI expands it out of band, so neither T3 nor I ever see what was actually sent.
  • Model-editable. Nothing anywhere lets a model revise a prompt I own. This one has zero prior art (see Duplicate search).

Plus a fifth, softer one: discoverability. A kickoff prompt only helps if it is present when I start a new project. A slash command I have to remember to type is not.

Proposed solution

The shape: a "kickoff prompt"

One named, fork-owned, server-stored preamble. Three surfaces: a chip at the new-project moment, an editor in Settings, and (phase 2) two MCP tools so the model can propose a revision.

The central UX call — committed: visible prefill, triggered by a chip. Not an invisible prepend.

Three candidates were on the table:

  • (a) prefill as visible, editable text in the composer
  • (b) invisibly prepend on send
  • (c) a one-tap chip at the new-thread empty state

Ship (c) as the trigger and (a) as the behaviour: a chip under the draft hero that prefills the composer with visible, editable text. Reject (b) outright.

Evidence for that call:

  1. (b) is architecturally wrong here. The client assembles the final message entirely client-side inside onSend and sends it as thread.turn.start (packages/client-runtime/src/operations/commands.ts:232-242); the same string builds the optimistic user bubble (apps/web/src/components/ChatView.tsx:4886, :4929, :5057). A server-side prepend makes the rendered transcript disagree with what the model received. A client-side prepend means editing ChatView.tsx — the fork's worst seam at risk 11466.
  2. (a) is nearly free. The composer's text is not local state: it is read from a zustand store (apps/web/src/components/chat/ChatComposer.tsx:707 useComposerThreadDraft(composerDraftTarget), :708, :716 store.setPrompt), and the Lexical editor is fully controlled — a layout effect rewrites editor state whenever the external value differs (apps/web/src/components/ComposerPromptEditor.tsx:1605-1610, $setComposerEditorPrompt(value, …)). Upstream already calls the store imperatively from outside React (apps/web/src/composerDraftStore.ts:3541). Prefill is one line and requires zero composer edits.
  3. (b) is the thing that already exists badly. An always-on invisible preamble is what --append-system-prompt in Claude launch args would give you (see Alternatives) — riding along on every message, invisible, undebuggable.
  4. The chip lands at the right moment. isDraftHeroState (ChatView.tsx:2421) renders <DraftHeroHeadline> — "What should we build in {project}?" — directly above the composer (ChatView.tsx:6058). That is literally the new-project moment.
  5. Every upstream precedent agrees. [Feature]: Saved snippets for frequently used prompts, with optional grouping pingdotgg/t3code#1547, [Feature]: Suggest a follow-up prompt when a turn finishes pingdotgg/t3code#5049 and Support local Codex custom prompts and $skills in the composer pingdotgg/t3code#737 are all explicit-invocation and never auto-send.

Explicitly not v1: auto-arming on every new draft. Composer drafts persist to localStorage under t3code:composer-drafts:v1 (composerDraftStore.ts:59), so auto-prefilled text is indistinguishable from text I typed, and I would have to delete a wall of text every time I just want to ask a quick question. Revisit as an opt-in toggle later.

Storage — fork-owned, never packages/contracts/src/settings.ts

Copy apps/server/src/t3x/autoResume/state.ts line for line: a single JSON file in the server state dir, an Effect Schema.Struct, mutations serialised through a SynchronizedRef and persisted atomically via apps/server/src/atomicWrite.ts.

apps/server/src/t3x/kickoffPrompt/state.ts → <config.stateDir>/t3x-kickoff-prompt.json:

{
  version: 1,
  text: string,                    // the live prompt
  updatedAtMs: number,
  modelEditingEnabled: boolean,    // DEFAULT false
  pendingProposal: null | { text, rationale, proposedAtMs, threadId, providerInstanceId },
  history: Array<{ text, savedAtMs, source: "default" | "user" | "model-accepted" }>, // cap 20
}

Every new field must decode with Schema.withDecodingDefaultKey — autoResume/state.ts:44-53 documents why: a missing required key fails the whole-file decode and the boot path silently drops everything.

Wire it in apps/server/src/t3x/index.ts exactly like AutoResumeStoreLive (Layer.effect reading ServerConfig.stateDir + Path.Path). Zero new upstream rows.

HTTP — /api/t3x/kickoff-prompt

apps/server/src/t3x/kickoffPrompt/http.ts, modelled on apps/server/src/t3x/autoResume/http.ts:

  • GET → { text, updatedAtMs, modelEditingEnabled, pendingProposal, history }
  • POST → { text?, modelEditingEnabled?, acceptProposal?, rejectProposal?, revertToVersionAtMs? }

Reuse the authenticateWithOperateScope mirror (autoResume/http.ts:38-57) — it is already a documented logic mirror in docs/t3x/SEAMS.md. Register through T3xRoutesLive. Zero new upstream rows. A raw route rather than a WS-RPC method, for the reason autoResume/http.ts:5-10 gives: an RPC would force edits to @t3tools/contracts plus apps/server/src/ws.ts (churn 41).

Web — chip

apps/web/src/t3x/KickoffPromptChip.tsx (fork-owned). Mounted in apps/web/src/components/chat/DraftHeroHeadline.tsx, which today returns a bare <h1> (:147-157) — wrap it in a fragment and append the chip. That component already receives activeProjectRef (:27-35), so no ChatView.tsx edit is needed.

Resolving the composer target: prefer the route param via TanStack Router (useParams({ from: "/_chat/draft/$draftId", shouldThrow: false })) read inside the fork component; fall back to useComposerDraftStore.getState().getDraftThreadByProjectRef(activeProjectRef)?.draftId (composerDraftStore.ts:338, :2213; ProjectDraftSession extends DraftSessionState { draftId: DraftId } at :306-308). ComposerThreadTarget = ScopedThreadRef | DraftId (:318) is not exported, but a bare DraftId is assignable, so no upstream export is needed.

On click:

useComposerDraftStore.getState().setPrompt(draftId, nextText);

Two behaviours the implementer must get right:

  • Do not clobber typed text. setPrompt replaces. If getComposerDraft(target)?.prompt is non-empty, prepend kickoffText + "\n\n" instead of replacing.
  • Idempotent. If the current prompt already starts with the kickoff text, do nothing (or no-op the chip into a "Kickoff prompt added" state).

The chip needs pointer-events-auto to match its siblings — the hero sits inside an absolute inset-x-0 bottom-full block (ChatView.tsx:6047) and the interactive elements inside DraftHeroHeadline all carry that class (:98, :141).

Cheaper alternative mount if the hero edit is rejected: apps/web/src/routes/_chat.draft.$draftId.tsx (churn 2) already holds draftId and renders <ChatView>, following the proven AutoResumeOverlay sibling pattern (apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18, :92). Tradeoff: absolute positioning instead of natural in-flow placement under the headline.

Web — Settings editor

apps/web/src/t3x/KickoffPromptSettings.tsx, exported as a bare <SettingsSection>, never its own <SettingsPageContainer> — that container is flex-1 overflow-y-auto (apps/web/src/components/settings/settingsLayout.tsx:199-222), so two siblings on one route produce two competing scroll panes.

Mount inside BetaSettingsPanel's existing container (apps/web/src/components/settings/BetaSettingsPanel.tsx, which already renders <SettingsPageContainer><SettingsSection>). Churn 3 — the cheapest settings surface in the tree. A brand-new settings page would cost ~10 churn instead (exhaustive Record<SettingsPath, …> entries in both settingsSearch.ts and SettingsSidebarNav.tsx, churn 9).

Contents:

  • The prompt textarea — copy SourceControlWritingSettings.tsx:118-136 verbatim: uncontrolled <Textarea key={text} defaultValue={text} onBlur={commit} rows={…} aria-label> nested inside a <SettingsRow>. apps/web/src/components/ui/textarea.tsx is churn 0.
  • <SettingResetButton> (settingsLayout.tsx:175) → reset to the shipped default.
  • Switch: "Let models propose edits to this prompt" — default OFF.
  • Pending-proposal card: old-vs-new diff, the model's rationale, which thread proposed it, Accept / Reject.
  • History list with one-click restore.

Phase 2 — the model-editable half, as a fork-owned MCP toolkit

This is the half with no prior art anywhere, and it is provider-agnostic for free: all five adapters already mount a per-thread t3-code HTTP MCP server — ClaudeAdapter.ts:3552, CursorAdapter.ts:547, GrokAdapter.ts:585, OpenCodeAdapter.ts:1221, and Codex via mcp_servers.t3-code.url=… (asserted in CodexAdapter.test.ts:579). The toolkit directory today contains only preview/.

apps/server/src/t3x/kickoffPrompt/mcp/{tools,handlers}.ts, following the Tool.make(...) / Toolkit.make(...) / Toolkit.toLayer(handlers) shape in apps/server/src/mcp/toolkits/preview/:

  • t3x_kickoff_prompt_get — returns the current text. Tool.Readonly true.
  • t3x_kickoff_prompt_propose — { text, rationale }. Writes pendingProposal. Returns a message saying it is queued for the user's review and is not live.

There is deliberately no tool that mutates the live prompt. The worst a prompt-injected or compromised model can do is queue a proposal I have to read and accept. That is the whole permission story, and it is stronger than anything an approval dialog could give.

The opt-in gate must be enforced inside the fork's handler, not delegated to T3's approval system. Two verified reasons:

  1. apps/server/src/provider/Layers/ClaudeAdapter.ts:3372-3378 — runtimeMode === "full-access" returns { behavior: "allow" } before any ApprovalRequestId is minted. Most threads run in full-access.
  2. Upstream [Bug]: "Always allow for session" permission is ignored for MCP tool calls (Repeatedly prompts for the same tools) pingdotgg/t3code#4512 reports that "Always allow for session" is ignored for MCP tool calls entirely.

So: read modelEditingEnabled from the store inside the handler and fail with a typed error the model can read and explain — the same shape as requireMcpCapability (apps/server/src/mcp/McpInvocationContext.ts:26-40), but keyed on a persisted setting rather than the issued capability set. Do not widen McpCapability (McpInvocationContext.ts:10, churn 3) or the hardcoded issued set (McpSessionRegistry.ts:131, churn 7) — that would cost two extra ledger rows.

Caller identity comes free and cannot be spoofed: McpInvocationContext carries environmentId/threadId/providerSessionId/providerInstanceId (McpInvocationContext.ts:12-19), bound to the request by bearer token in the auth middleware (McpHttpServer.ts:70-89). It does not carry projectId.

Mount point (UNVERIFIED — see Risks). McpServer.layerHttp outputs the McpServer service, and McpHttpServer.layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)) (apps/server/src/mcp/McpHttpServer.ts:206-225), so McpServer is in that layer's public output. Therefore apps/server/src/server.ts:427:

// today
McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),

// proposed
T3xMcpToolkitsLive.pipe(
  Layer.provideMerge(McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer))),
),

server.ts is already a ledger row (+3 lines, churn 29, risk 87) — this grows it rather than adding a row, and keeps apps/server/src/mcp/* untouched.

Tool discoverability across providers. The only place T3 tells a model the t3-code server exists is apps/server/src/provider/CodexDeveloperInstructions.ts:7, and it is Codex-only. Rather than add per-adapter hints, let the kickoff prompt advertise its own tools: when modelEditingEnabled is on, append a line to the resolved text. That is fork-owned data, zero seam, and works on all five providers:

If you learn something in this session that would make this kickoff prompt work better next time, call t3x_kickoff_prompt_propose with the full revised text and a one-line rationale. It queues a proposal for my review; it does not change anything.

Default prompt

Ship a working default so the feature needs zero setup (autoResume's DEFAULT_RESUME_PROMPT = "continue" at config.ts:106 is the precedent). Something like:

You are helping me plan a new project from scratch. Before writing any code:

1. Ask clarifying questions, a few at a time, until you can state the problem, the
   user, and the smallest shippable version in one paragraph each.
2. Propose 2-3 candidate approaches with real tradeoffs, and recommend one.
3. Name the stack and justify each choice against my constraints, not against fashion.
4. Sketch the smallest thing that could ship this week, and what it deliberately omits.
5. Stop and wait for my go-ahead before scaffolding anything.

Here is what I want to build:

Why this matters

Who benefits, concretely:

  • Non-Claude users get this at all. Today a Codex, Cursor, Grok or OpenCode user has no reusable-prompt mechanism in T3's composer — only ClaudeProvider populates slashCommands (ClaudeProvider.ts:747). This works identically on all five providers because it is plain text written into upstream's own composer store.
  • Remote and mobile clients get it. ~/.claude/commands/*.md lives on the host filesystem and is unreachable from a browser or phone paired to a remote T3 server. /api/t3x/kickoff-prompt is reachable from any paired client, over the same primaryEnvironmentHttpLayer path AutoResumeOverlay already uses.
  • The new-project moment becomes a single tap. The chip sits under "What should we build in {project}?" — the exact instant the prompt is useful. That is the difference between a feature I use and a slash command I forget exists.
  • What I send is what the model gets. The preamble is in the composer, in my editor, before I hit send — and identical to what lands in the transcript. The /kickoff path can never offer that, because the CLI expands the command out of band and T3 never sees the expansion.
  • The prompt gets better without me maintaining it. After a planning session the model has just learned where my preamble was vague. Today that knowledge evaporates. A propose-only tool captures it as a reviewable diff.

What it unlocks for the fork specifically: this is the first fork feature to add a t3-code MCP tool. Once T3xMcpToolkitsLive exists and compiles, issues #42 (wake_me) and #43 (provider-agnostic orchestration) inherit the mount for free — they are both blocked on exactly this plumbing.

Smallest useful scope

Phase 1 — no MCP, no model editing, no history. Ship this alone; it fixes four of the five gaps.

  1. apps/server/src/t3x/kickoffPrompt/state.ts — JSON store at <stateDir>/t3x-kickoff-prompt.json, { version: 1, text, updatedAtMs } only. Copy autoResume/state.ts.
  2. apps/server/src/t3x/kickoffPrompt/http.ts — GET/POST /api/t3x/kickoff-prompt, operate scope, registered through T3xRoutesLive in apps/server/src/t3x/index.ts.
  3. apps/web/src/t3x/KickoffPromptChip.tsx + a ~5-line mount in DraftHeroHeadline.tsx (wrap the <h1> at :147-157 in a fragment).
  4. apps/web/src/t3x/KickoffPromptSettings.tsx (bare <SettingsSection>: textarea + reset) + a ~3-line mount inside BetaSettingsPanel.tsx's existing container.

Upstream cost of Phase 1: two new ledger rows, ~8 changed lines, combined risk ≈ 34. Zero server rows, zero contracts rows, zero ChatView.tsx, zero ChatComposer.tsx.

upstream file churn fork Δ risk
apps/web/src/components/chat/DraftHeroHeadline.tsx 5 ~+5 ~25
apps/web/src/components/settings/BetaSettingsPanel.tsx 3 ~+3 ~9

(Churn re-measured against merge-base 64bf01619 with the docs/t3x/SEAMS.md recipe on 2026-08-02.)

Phase 2 — the model-editable half. modelEditingEnabled switch (default OFF), the two MCP tools, pendingProposal + accept/reject diff card, history + one-click revert, and the tool-advertising suffix on the resolved prompt. Adds ~2 lines to the existing apps/server/src/server.ts row (risk +~58).

Deliberately deferred, in priority order:

  • Mobile parity. apps/mobile/src/features/threads/ThreadComposer.tsx is churn 16 / risk 480 and already a fork seam. Web-only for v1.
  • Registration in upstream's settings-search catalog (apps/web/src/components/settings/settingsSearch.ts, churn 1). Near-free in risk terms but it is a third new ledger row against an explicit tripwire; the fork's existing settings row already made this same call and is unfindable via search. Judgement call, revisit.
  • A named library of N prompts. The user asked for one; promptStashStore.ts's 20-entry cap hints the latent need is a small set, but a single-prompt v1 is strictly less code and generalises cleanly into upstream [Feature]: Saved snippets for frequently used prompts, with optional grouping pingdotgg/t3code#1547's list later.
  • A per-project override in t3.json (packages/contracts/src/t3ProjectFile.ts:61-86 — it has $schema, iconPath, scripts, no prompt field). Also note McpInvocationScope carries threadId but not projectId, so a project-scoped variant needs a threadId→project resolve inside the handler.
  • Auto-arming the prompt on every new draft.
  • Detecting an existing ~/.claude/commands/kickoff.md and offering to import it.

Alternatives considered

Native Claude Code slash commands already solve most of the insertion half. Honestly.

This is the strongest alternative and it deserves to be stated plainly rather than buried.

~/.claude/commands/kickoff.md containing the preamble plus $ARGUMENTS, then typing /kickoff my app idea in T3's composer with autocomplete, works today with zero code. The chain is verified end to end: ClaudeAdapter.ts:3528-3529 sets systemPrompt: { type: "preset", preset: "claude_code" } and settingSources: [...CLAUDE_SETTING_SOURCES] (= ["user","project","local"], :884), so the CLI reads the user's whole filesystem config; ClaudeProvider.ts:747 harvests init.commands; ChatComposer.tsx:1099-1110 renders them in the / menu; ChatComposer.tsx:1728 inserts /<name> . I already have ~/.claude/commands/animate.md and it works.

If you only use Claude, that is 80% of this feature and you should just do that. This issue's justification is the remaining 20%, which the slash-command path structurally cannot reach:

gap why slash commands cannot close it
cross-provider only ClaudeProvider produces slashCommands; Codex/Cursor/Grok/OpenCode get nothing
remote clients ~/.claude/commands is a host-filesystem directory; a phone paired to a remote server cannot see or edit it
transcript honesty T3 sends /kickoff … verbatim; the CLI expands it out of band and T3 never sees the expansion
model-editable a .md file the model could rewrite has no permission gate, no diff, no history, no revert
discoverability you have to remember to type it

--append-system-prompt via Claude launch arguments — UNVERIFIED

Settings → Providers → Claude → Launch arguments is parsed with parseCliArgs(claudeSettings.launchArgs).flags (ClaudeAdapter.ts:3489) and handed to the SDK as extraArgs, documented as "Additional CLI arguments to pass to Claude Code" (@anthropic-ai/claude-agent-sdk@0.3.170, sdk.d.ts:1433). So --append-system-prompt "…" is plausibly a zero-code always-on preamble today.

Unverified: whether it takes effect at all, given the adapter also sets systemPrompt: { type: "preset", preset: "claude_code" } explicitly at :3528. The SDK exposes a first-class append field on that same preset object (sdk.d.ts:1933: "{ type: 'preset', preset: 'claude_code', append: '...' } – Use default prompt with appended instructions"), which suggests the structured field, not the CLI flag, is the intended path — and that the flag may be overridden or conflict.

Experiment that settles it: put --append-system-prompt "Always begin every reply with the token XYZZY." in Claude launch args, start a thread, and see whether replies start with XYZZY. Five minutes.

Even if it works, it is the wrong product: always-on, invisible, on every message, Claude-only, and unreachable from a remote client. It is exactly option (b) that this issue rejects.

Prompt stash (Cmd+S)

apps/web/src/promptStashStore.ts is provider-agnostic by explicit design (:29-33) and already wired into the composer. But it is not a library: restore is a pop — "Removes and returns an entry from the queue (restore + delete)" (:205) — entries are unnamed, capped at 20 (:17), and stored in browser localStorage (:7). Per-client, not synced to mobile, not server-authoritative, invisible to models. Pinning a stash entry would need MAX_STASH_ENTRIES and pop semantics changed, and would still fail the Settings and model-edit requirements.

Put it in packages/contracts/src/settings.ts like sourceControlWritingStyle

The obvious move, and the wrong one for this fork. That file is ledger row +7/-2, churn 18, risk 162, it is a persisted schema, and docs/t3x/SEAMS.md names it as the anchor of the recurring issue-#29 rebase conflict. Adding a field costs two edits (the ServerSettings struct at :508 and ServerSettingsPatch at :649-653). The fork-owned JSON store + /api/t3x/* route pattern gets the same reach at zero contracts cost. Copy SourceControlWritingSettings.tsx as the UI template; do not copy its storage.

Have the model edit a file with its normal Write tool

autoResume does exactly this: .t3x/resume-prompt.md in the workspace, resolved server-side (autoResume/config.ts:82-110), editable by the agent under the provider's own permission prompt. Tempting because it costs nothing. Rejected here because the kickoff prompt is global, so it must live in the server state dir, not a workspace — and a raw Write to the state dir gives no schema validation, no proposal/accept step, no history, and no audit. The MCP toolkit is the price of a real permission story.

A /newproject built-in slash command

Rejected. There is no slash-command registry — packages/shared/src/composerTrigger.ts is a pure string module with no registry (:56-119, :137-149), and the built-in list is an inline array literal in ChatComposer.tsx:1076-1114 with a matching selection switch at :1706-1725. Two edits to a churn-34 file, roughly 10× the rebase cost of a hero-mounted chip for the same action.

Wait for upstream pingdotgg#5049's composer banner

#5049 ships a banner-slot + click-to-insert PoC (+463/−15). If it lands, the insert plumbing is free. But it is a turn-finished suggestion, not a new-project affordance, and it is still open with no maintainer signal. Not worth blocking on.

Risks or tradeoffs

Duplicate risk with two live upstream issues

The single biggest risk is this getting closed as a duplicate. See Duplicate search for the deltas — the issue must cite pingdotgg#1547 and pingdotgg#4490 by number in its opening.

Worth considering: pitch this as a superset of pingdotgg#1547 rather than beside it. A single-prompt design is strictly less code than a grouped snippet library, pingdotgg#1547's author volunteered to implement, and pingdotgg#1547 itself named its main risk as "added composer complexity and avoiding overlap/confusion with skills, slash commands, or automations" — which two competing composer surfaces would make worse.

Prompt injection → proposal spam

A hostile repo could steer the model into proposing a poisoned kickoff prompt ("always run this script first"). Mitigations, in order of strength:

  1. No tool mutates the live prompt. Propose-only is the whole design. The blast radius is a proposal I must read.
  2. Default OFF. modelEditingEnabled: false until explicitly turned on in Settings.
  3. Diff + rationale + originating thread shown before accept.
  4. History + one-click revert after accept.

Residual: proposal spam as a nuisance vector. Cap at one pending proposal, overwrite-latest, and record which thread proposed it.

Two new ledger rows against an explicit tripwire

docs/t3x/SEAMS.md is at 34 rows (+1616/-187) and says: "Before adding row 35, re-isolate something instead. Prefer fork-owned files even when an in-place edit is smaller."

Facing that squarely: DraftHeroHeadline.tsx and BetaSettingsPanel.tsx would be rows 35 and 36, at combined risk ≈ 34 — the two cheapest mounts available (churn 5 and 3; the alternatives are 63 and 18). Everything else is fork-owned or grows an existing row.

The re-isolation the tripwire asks for is already named in the ledger itself: docs/user/providers-claude.md (+86/-33, churn 1, risk 119) is annotated "the one row worth upstreaming, which would remove it." Land that upstream PR alongside this feature and the ledger nets 34 → 35 rows with lower total risk. If a reviewer will not accept two rows, drop the hero mount for _chat.draft.$draftId.tsx (churn 2, risk ≈ 4) and accept absolute positioning.

apps/server/src/mcp is under active upstream churn, including a rename

20+ commits in 90 days, including f7c422d58 "Refactor MCP services into top-level modules". That is exactly the SEAMS.md "upstream can RENAME a seam file" hazard the fork has already been bitten by. Mitigation: depend only on McpHttpServer.layer's public output; never import from McpInvocationContext.ts, McpSessionRegistry.ts, or toolkits/preview/. (Requiring McpInvocationContext in a tool's dependencies is unavoidable if the tool wants caller identity — accept that one import, or skip caller identity entirely for v1 since a global prompt does not need it.)

The MCP mount at server.ts:427 is UNVERIFIED

The reasoning is sound — McpServer.layerHttp outputs McpServer | McpServerClient, and McpHttpServer.layer composes it with Layer.provideMerge, so McpServer is in the output — but it has not been compiled.

Experiment: add a one-tool stub toolkit under apps/server/src/t3x/kickoffPrompt/mcp/, wire it as T3xMcpToolkitsLive.pipe(Layer.provideMerge(McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)))) at server.ts:427, and run the server typecheck. If an unsatisfied McpServer requirement leaks into makeRoutesLayer's type, fall back to a 2-line edit in apps/server/src/mcp/McpHttpServer.ts (import + one Layer.mergeAll member) — a third new row at churn 6, still cheap. Do this before committing to Phase 2's design.

Structural collision with upstream pingdotgg#3164

#3164 "Add Automations & Triggers (for loops)" is labelled 🚧 In Progress upstream and its body says "It could also support template triggers and automation/triggers that can be reused." If automations ship a reusable prompt-template object, a separate global-prompt store becomes a parallel path — the fork's recorded recurring hazard, where a fork path that duplicates an upstream capability silently bypasses new upstream guards. Check pingdotgg#3164's branch state before Phase 1 lands (upstream PR pingdotgg#3638 shipped schedule_task/delegate_task on a stack branch, not main — the same trap already caught issues #42/#43).

Note the prefill design reduces this hazard on the send side: the chip writes to upstream's own composer store and the message goes through upstream's own onSend, so there is no parallel send path to keep in sync — unlike the fork's outbox.

Smaller edges

  • setPrompt replaces, it does not append. Tapping the chip on a non-empty draft would destroy typed text. Handle it (prepend + blank line) or the feature is a data-loss bug.
  • Composer drafts persist to localStorage (t3code:composer-drafts:v1). Prefilled text survives reload — good — but a stale copy also survives a later Settings edit. Acceptable; do not try to reconcile.
  • OpenCode coverage gap. OpenCodeAdapter.ts:1218 registers t3-code only if (mcpSession && !server.external), so an externally-hosted OpenCode server gets no fork tools. Phase 1 (prefill) is unaffected; Phase 2's model-edit half silently does not exist there. Say so in the Settings copy.
  • MCP tool-name classification. classifyRequestType (ClaudeAdapter.ts:652) is substring-based on the raw tool name (isReadOnlyToolName matches "read"/"view"/"grep"/"glob"/"search"). Naming a tool *_read would misfile it as file_read_approval in non-full-access modes. t3x_kickoff_prompt_get / _propose avoid this.
  • No audit trail. Nothing in the MCP path records tool-call side effects to a user-visible thread history. A model proposing an edit to my global prompt is arguably worth an inline timeline entry — undecided, and out of scope for v1.
  • useComposerDraftStore is web-only. Any future mobile parity needs a separate prefill path; do not design Phase 1 as if it will be shared.

Examples or references

Upstream issues to cite (all verified live on 2026-08-02)

File:line evidence index for the implementer

Existing mechanisms (do not rebuild)

  • apps/server/src/provider/Layers/ClaudeAdapter.ts:884 — CLAUDE_SETTING_SOURCES = ["user","project","local"]; :3528-3529 — claude_code preset + settingSources
  • apps/server/src/provider/Layers/ClaudeProvider.ts:747 — slashCommands: parseClaudeInitializationCommands(init.commands)
  • apps/web/src/components/chat/ChatComposer.tsx:1099-1110 — providerSlashCommandItems; :1728 — const replacement = `/${item.command.name} `
  • apps/server/src/provider/Layers/CodexProvider.ts:394 — client.request("skills/list", …) (Codex has skills, no slash commands)
  • apps/web/src/promptStashStore.ts:7, :17, :205 — stash key, 20-entry cap, pop-on-restore
  • packages/contracts/src/settings.ts:421-429, apps/web/src/components/settings/SourceControlWritingSettings.tsx:118-136, apps/server/src/git/GitManager.ts:612-615 — the Settings-prompt precedent

Composer prefill (the whole Phase 1 mechanism)

  • apps/web/src/components/chat/ChatComposer.tsx:707 useComposerThreadDraft(composerDraftTarget), :708, :716 store.setPrompt
  • apps/web/src/components/ComposerPromptEditor.tsx:1605-1610 — shouldRewriteEditorState → $setComposerEditorPrompt(value, …)
  • apps/web/src/composerDraftStore.ts:59 storage key, :306-308 ProjectDraftSession, :318 ComposerThreadTarget, :338/:2213 getDraftThreadByProjectRef, :404 setPrompt signature, :3541 imperative getState() precedent
  • apps/web/src/components/ChatView.tsx:2421 isDraftHeroState, :6046-6062 hero render block, :4886/:4929/:5057 outgoingMessageText (the send path this design deliberately avoids)
  • apps/web/src/components/chat/DraftHeroHeadline.tsx:27-35 props, :147-157 the bare <h1> return
  • apps/web/src/routes/_chat.draft.$draftId.tsx:85-88 — cheaper alternative mount

Settings

  • apps/web/src/components/settings/BetaSettingsPanel.tsx — <SettingsPageContainer><SettingsSection> shape to nest inside
  • apps/web/src/components/settings/settingsLayout.tsx:91 SettingsSection, :125 SettingsRow, :175 SettingResetButton, :199-222 SettingsPageContainer (flex-1 overflow-y-auto — the two-scroll-pane hazard)
  • apps/web/src/components/settings/settingsSearch.ts — the catalog v1 skips

Fork patterns to copy

  • apps/server/src/t3x/index.ts — the aggregator; T3xLayerLive / T3xRoutesLive, and the memoisation note explaining why store layers are module-scoped values
  • apps/server/src/t3x/autoResume/state.ts:1-14 (why a JSON file, not a DB migration), :44-53 (withDecodingDefaultKey or the whole file fails to decode), :100 makeAutoResumeStore
  • apps/server/src/t3x/autoResume/http.ts:5-10 (raw route vs WS-RPC rationale), :38-57 (authenticateWithOperateScope mirror)
  • apps/server/src/t3x/autoResume/config.ts:106-110 — DEFAULT_RESUME_PROMPT, resolveResumePrompt precedence
  • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,:92 — the fork-owned-component-mounted-on-a-route pattern
  • apps/server/src/server.ts:59,:224,:422 (existing 3-line seam), :427 (the MCP mount to grow)

MCP

  • apps/server/src/mcp/toolkits/preview/tools.ts:27-52 Tool.make shape, :203-236 Toolkit.make
  • apps/server/src/mcp/toolkits/preview/handlers.ts:63-98 handler record + satisfies Parameters<typeof X.toLayer>[0]
  • apps/server/src/mcp/McpHttpServer.ts:70-89 bearer-token → McpInvocationContext, :206-225 layer composition
  • apps/server/src/mcp/McpInvocationContext.ts:10 closed McpCapability union (do not widen), :12-19 scope fields, :26-40 requireMcpCapability as the error template
  • Five adapter mounts: ClaudeAdapter.ts:3552, CursorAdapter.ts:547, GrokAdapter.ts:585, OpenCodeAdapter.ts:1221 (gated by !server.external at :1218), CodexAdapter.test.ts:579
  • apps/server/src/provider/Layers/ClaudeAdapter.ts:3372-3378 — the full-access short-circuit that forces an in-handler gate
  • apps/server/src/provider/CodexDeveloperInstructions.ts:7 — the Codex-only tool advertisement

SDK

  • @anthropic-ai/claude-agent-sdk@0.3.170 sdk.d.ts:1433 extraArgs?: Record<string, string | null>; :1933 { type: 'preset', preset: 'claude_code', append: '...' }

Seam ledger — docs/t3x/SEAMS.md: header totals (34 files, +1616/-187, merge-base 64bf01619), the row-35 tripwire, "Reading the risk column", and the docs/user/providers-claude.md row annotated as the one worth upstreaming.

Churn figures in this issue were re-measured on 2026-08-02 with the ledger's own recipe: MB=$(git merge-base main upstream/main); MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l.


Duplicate search performed before filing

Fork (radroid/t3code): no duplicate, no overlap. gh issue list --repo radroid/t3code --state all --limit 100 returns exactly 24 issues (#4-#45). None concerns prompts, prompt storage, composer prefill, or settings-owned text. Closest by theme: #43 (a fork-owned MCP toolkit for orchestration) and #36 (voice input into the composer, closed NOT_PLANNED) — both touch machinery this feature reuses; neither is a duplicate. #42 and #43 are in fact unblocked by this feature's T3xMcpToolkitsLive mount.

Upstream (pingdotgg/t3code): no exact duplicate, but two open issues own roughly half each. The full 1623-issue title corpus was dumped (gh issue list --state all --limit 6000 --json number,title,state,url) and grepped against 32 terms, yielding 65 candidates; ~35 additional body-level gh search issues queries were run.

Deltas that must appear in the issue body:

The model-editable half has zero prior art in either repo. Empty result sets across ~35 body-level queries covering: prompt library, saved prompt, custom instructions, project instructions, prefill, composer default, output style, default prompt, meta prompt, prompt enhance, improve my prompt, starter prompt, project kickoff, onboarding prompt, app idea, self-improving, user memory, macro. No open PR upstream matches "prompt template" or "saved prompt snippet" either. That property — a model revising the user's own saved prompt under an explicit, default-OFF permission with diff, history and revert — is the genuinely novel part and should carry the issue's justification.

Contribution

  • I would be open to helping implement this.

Activity

  1. radroid commented on Aug 7, 2026

    @radroid
    OwnerAuthor

    Priority triage — 2026-08-07: Rank 5 / 11 — Tier 3 (shovel-ready feature)

    Best-specified issue in the backlog and self-contained (apps/web + one contract setting), with no upstream dependency. This is the feature to build first once the keep-alive (#49) and active bugs (#39, #41) are cleared.

  2. radroid commented on Aug 7, 2026

    @radroid
    OwnerAuthor

    Triage 2026-08-07 — the composer geometry this is specified against has moved upstream

    The design's central UX call — a chip under the draft hero that prefills the composer with visible text — rests on specific structure in ChatView.tsx and ChatComposer.tsx (isDraftHeroState at ChatView.tsx:2421, <DraftHeroHeadline> rendered above the composer at :6058, useComposerThreadDraft at ChatComposer.tsx:707). All of those line numbers are against the 2026-08-02 base.

    upstream/main is 103 commits ahead and unmerged (#49). Inside that range:

    • 48aa875c feat(web): remove Build/Plan toggle from the composer (#5551) — removes a control from exactly the region this feature wants to add one to.
    • a8cd2ad2 fix(web): plans stop hijacking the UI, fold into chat instead (#5558) — changes what the chat surface does with plan mode.
    • 9235c83e fix(web): keep the composer command menu anchored to the composer (#5336) — touches the / menu this issue's "what already works" section §1 depends on.
    • 85b1734d feat(web): add modular theme library (#5226) and ab3b55e2 also touch the composer.

    ChatView.tsx takes 20 upstream commits in this range in total and is the file the last three sync attempts stopped on.

    None of that invalidates the design. The argument for (a) visible prefill over (b) invisible prepend is structural — the client assembles the final string in onSend and the same string builds the optimistic bubble — and that reasoning survives a refactor. But the "prefill is one line and requires zero composer edits" claim must be re-verified after #49, because it is the entire cost case for this feature, and 48aa875c is a composer edit in the same neighbourhood.

    Recommend: leave open, re-verify the zero-edit claim post-sync, then build. The Settings editor half (§2's sourceControlWritingStyle.customInstructions precedent) is unaffected and could be specified independently — though note packages/contracts/src/settings.ts takes 4 upstream commits in this range and was a conflict file in the 08-07 sync run, so it is not a free surface right now either.

    Priority: P6.

  3. radroid commented on Sep 12, 2026

    @radroid
    OwnerAuthor

    Implementation plan — 2026-09-12

    What changed since the last triage

    • The rename landed: fork code is apps/server/src/coil/, apps/web/src/components/coil/, docs at docs/coil/SEAMS.md (34-row ledger is gone — check current header before citing a row number).
    • BetaSettingsPanel.tsx no longer exists — the §2 mount point is dead. The loops feature (feat(coil): loops — a durable supervisor for unattended threads #133) established the replacement pattern instead: a dedicated route apps/web/src/routes/settings.loops.tsx rendering apps/web/src/components/coil/LoopsSettings.tsx, registered in settingsSearch.ts and SettingsSidebarNav.tsx. Those two files are now already fork rows (loops paid that cost), so a /settings/kickoff-prompt entry (or folding into a shared "Coil" settings page) is an incremental add to an already-touched file, not a new ledger row — the calculus in the issue's "two new ledger rows" risk section is stale and more favorable now.
    • The composer-prefill anchors mostly hold: isDraftHeroState still exists in ChatView.tsx (now computed via resolveDraftHeroState around line ~2867, consumed ~6202), DraftHeroHeadline.tsx is still a fork-editable component receiving activeProjectRef/draftId, and useComposerThreadDraft/useComposerDraftStore/setPrompt all still exist in composerDraftStore.ts (just renumbered). The "prefill is one line, zero composer edits" claim still checks out.
    • The MCP mount question is answered, not just verified: loops (feat(coil): loops — a durable supervisor for unattended threads #133) shipped a real toolkit at apps/server/src/mcp/toolkits/loop/ merged into apps/server/src/mcp/McpHttpServer.ts via Layer.mergeAll(PreviewToolkitRegistrationLive, LoopToolkitRegistrationLive /* coil fork seam */). That line is the aggregator this issue's Phase 2 wanted to build — copy that merge site directly instead of inventing T3xMcpToolkitsLive. Upstream meanwhile added its own device and pullRequests toolkits alongside preview, so apps/server/src/mcp/toolkits/ churn is higher than assumed — irrelevant to the fork row (still 0 for apps/server/src/coil/).
    • Upstream has not shipped anything overlapping this feature (no prompt-library, no kickoff mechanism). packages/contracts/src/settings.ts keeps growing (30 commits since the last sync's merge-base) — the "don't touch it" recommendation is stronger, not weaker.
    • Sync [coil-sync] daily sync needs attention (conflict) #135 (~867 commits behind as of today, not ~100 — the estimate in this task's framing is stale) is open with conflicts, unmerged. ChatView.tsx continues to be heavily touched upstream.

    Recommended scope for v1

    Ship exactly the issue's original Phase 1: server-side JSON store + /api/t3x/kickoff-prompt route + composer chip + a settings entry. Defer Phase 2 (model-editable proposals via MCP) to a follow-up now that the toolkit-merge pattern is proven — it's cheap to add later and doesn't need to block v1.

    Phases

    1. Server store + route (fork-owned only). Add apps/server/src/coil/kickoffPrompt/{state,http,config}.ts, copying coil/autoResume/state.ts and coil/webPush/http.ts verbatim for shape. Register in apps/server/src/coil/index.ts (CoilLayerLive/CoilRoutesLive). Zero upstream files. Tests: state round-trip + http auth-scope test mirroring autoResume/http.test.ts.
    2. Web chip — apps/web/src/components/coil/KickoffPromptChip.tsx, mounted in DraftHeroHeadline.tsx (~5 lines). Verify current return shape before wrapping it (grep confirms it's still a plain function component, but re-check the JSX return, not just the props, since it moved). Tests: a store-level test that clicking prepends rather than replaces non-empty drafts.
    3. Web settings entry — reuse the loops precedent: either add a route under settings.kickoff-prompt.tsx or fold a <KickoffPromptSettings> section into an existing fork settings page if one is shared. Register in settingsSearch.ts + SettingsSidebarNav.tsx (already-touched files per above). Surfaces: web only for v1; mobile explicitly deferred (composer draft store is web-only); desktop inherits web automatically.
    4. (Follow-up issue, not this v1) MCP propose tool — apps/server/src/mcp/toolkits/kickoffPrompt/{tools,handlers}.ts, merged at the now-proven McpHttpServer.ts toolkit-merge site. Default-off modelEditingEnabled, propose-only, no mutating tool. Provider surfaces: all five get it for free via the shared MCP mount (unchanged from the original design).

    Decisions the maintainer must make

    1. Where does kickoff-prompt settings live? A new dedicated route (matches loops' precedent, cheap) vs. folding into a general "Coil" settings page if one gets built for multiple small toggles. Recommend: dedicated route now, consolidate later if the fork accumulates a third small settings surface.
    2. Ship Phase 2 (model-editable) now or as a separate issue? Recommend: separate issue — the toolkit-merge pattern is proven, so there's no risk in deferring, and v1 alone closes 4 of the 5 gaps the issue names.

    Blockers / sequencing

    No hard blocker. Independent of sync #135 (touches zero upstream files that are mid-conflict). Should land before or alongside any future MCP toolkit work since it would be only the second fork toolkit to use the merge site loops established — good to prove the pattern generalizes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions