Expose MCP prompts as Discord slash commands, which adds a summon path the access policy does not model #127

Closed
opened 2026-08-12 01:57:47 +00:00 by coilyco-ops · 11 comments
Member

HTTP callers can select a server prompt as of 4d5fb97 (see coilyco-gaming/sirens-echo#118). Discord cannot, because it has no selection surface, and prompts are user-controlled by spec so Echo will not inject one on its own.

Discord slash commands are the faithful surface. They are literally the spec's illustration of the control model, Discord's command options map onto PromptArgument including required and description, autocomplete comes free, and notifications/prompts/list_changed maps onto re-registering.

The blocking question is not technical

A slash command is a new summon path, and the access policy does not model it.

Today Echo answers on a mention in an allowed channel, or a direct message when enabled. Those gates live in AccessPolicy and the channel scope. A slash command bypasses the mention gate by construction: Discord routes the interaction to the bot because the user picked it, not because they mentioned anyone.

So before any code:

  • Which channels may carry a prompt command, and is that the same allowlist as mentions or a narrower one?
  • Does the guild allowlist apply unchanged?
  • Do prompt commands respect the per-user and per-context admission budgets? They should, but the limiter keys off a summon context that would need a case for interactions.
  • Are they available in direct messages, given DMs are opt-in today because a DM has no guild moderation behind it?
  • Registering application commands is a write to Discord's API and needs the applications.commands scope. Echo's Discord surface is read-and-reply today, so this widens what its token does.

Scope once that is settled

  • Register commands per guild from the cached prompt list, refreshing on prompts/list_changed.
  • Map PromptArgument onto command options, using required and description.
  • Route the interaction through the same turn path, admission, grounding, and response validation as every other ingress. Nothing bypasses those.
  • Consider Discord's command-count limits if the roster ever publishes many prompts across servers.

Filed separately from coilyco-gaming/sirens-echo#118 because the access-policy decision deserves its own answer rather than arriving as a side effect of a feature. Labelled interactive since it needs a human decision before implementation, not a headless run.

HTTP callers can select a server prompt as of `4d5fb97` (see coilyco-gaming/sirens-echo#118). Discord cannot, because it has no selection surface, and prompts are user-controlled by spec so Echo will not inject one on its own. Discord slash commands are the faithful surface. They are literally the spec's illustration of the control model, Discord's command options map onto `PromptArgument` including required and description, autocomplete comes free, and `notifications/prompts/list_changed` maps onto re-registering. ## The blocking question is not technical **A slash command is a new summon path, and the access policy does not model it.** Today Echo answers on a mention in an allowed channel, or a direct message when enabled. Those gates live in `AccessPolicy` and the channel scope. A slash command bypasses the mention gate by construction: Discord routes the interaction to the bot because the user picked it, not because they mentioned anyone. So before any code: * Which channels may carry a prompt command, and is that the same allowlist as mentions or a narrower one? * Does the guild allowlist apply unchanged? * Do prompt commands respect the per-user and per-context admission budgets? They should, but the limiter keys off a summon context that would need a case for interactions. * Are they available in direct messages, given DMs are opt-in today because a DM has no guild moderation behind it? * Registering application commands is a **write to Discord's API** and needs the `applications.commands` scope. Echo's Discord surface is read-and-reply today, so this widens what its token does. ## Scope once that is settled * Register commands per guild from the cached prompt list, refreshing on `prompts/list_changed`. * Map `PromptArgument` onto command options, using required and description. * Route the interaction through the same turn path, admission, grounding, and response validation as every other ingress. Nothing bypasses those. * Consider Discord's command-count limits if the roster ever publishes many prompts across servers. Filed separately from coilyco-gaming/sirens-echo#118 because the access-policy decision deserves its own answer rather than arriving as a side effect of a feature. Labelled `interactive` since it needs a human decision before implementation, not a headless run.
Author
Member

Decision: deferred past August 19, and the reason is the token

Direction from Kai, 2026-08-12 session.

This issue asked for a human decision before implementation. The decision is not yet — and it turns on the last bullet of the blocking question rather than on the access-policy shape.

Registering application commands is a write to Discord's API and needs the applications.commands scope. Echo's Discord surface is read-and-reply today, so this widens what its token does.

Widening what Echo's Discord token can do in the week before a permanent public livestream is not a trade worth making for a feature the demo does not need. The access-policy questions in this issue are good ones and remain unanswered on purpose — answering them now would invite the implementation to follow.

Revisit after August 19. The questions to answer then are exactly the ones already written here: which channels may carry a prompt command, whether the guild allowlist applies unchanged, whether prompt commands respect per-user and per-context admission budgets, and whether they are available in DMs.

This decision binds #147 too

#147 (structured commands and thread-to-job binding) needs the same applications.commands scope for the same reason, and its own body already notes it. The scope widening is one decision covering both issues, deferred once.

That splits #147 unevenly: its thread-to-job binding half needs no new Discord scope and is unaffected, while its structured-command half waits on this. Noted there.

Unchanged

The technical reading in this issue stands and needs no rework when it is picked back up — slash commands remain the faithful surface for MCP prompts, Discord's command options really do map onto PromptArgument, and notifications/prompts/list_changed really does map onto re-registering. Nothing here is wrong. It is a timing call.

Keeping the interactive label. It still needs a human decision, just a later one.

## Decision: deferred past August 19, and the reason is the token Direction from Kai, 2026-08-12 session. This issue asked for a human decision before implementation. The decision is **not yet** — and it turns on the last bullet of the blocking question rather than on the access-policy shape. > Registering application commands is a **write to Discord's API** and needs the `applications.commands` scope. Echo's Discord surface is read-and-reply today, so this widens what its token does. Widening what Echo's Discord token can do in the week before a permanent public livestream is not a trade worth making for a feature the demo does not need. The access-policy questions in this issue are good ones and remain unanswered on purpose — answering them now would invite the implementation to follow. **Revisit after August 19.** The questions to answer then are exactly the ones already written here: which channels may carry a prompt command, whether the guild allowlist applies unchanged, whether prompt commands respect per-user and per-context admission budgets, and whether they are available in DMs. ## This decision binds #147 too #147 (structured commands and thread-to-job binding) needs the same `applications.commands` scope for the same reason, and its own body already notes it. The scope widening is one decision covering both issues, deferred once. That splits #147 unevenly: its thread-to-job binding half needs no new Discord scope and is unaffected, while its structured-command half waits on this. Noted there. ## Unchanged The technical reading in this issue stands and needs no rework when it is picked back up — slash commands remain the faithful surface for MCP prompts, Discord's command options really do map onto `PromptArgument`, and `notifications/prompts/list_changed` really does map onto re-registering. Nothing here is wrong. It is a timing call. Keeping the `interactive` label. It still needs a human decision, just a later one.
Author
Member

The blocking question is answered, in code

This issue says the access-policy decision has to come before any code, and it was right to. #147 has now landed slash commands for job actions, and it answered each of these rather than routing around them. Recording the answers here, because this issue is where the question was asked.

A slash command is a new summon path, and the access policy does not model it.

It does now, by not needing to. An interaction is evaluated by AccessPolicy.Evaluate against the same summonContext a message builds, so a command reaches nothing a message from the same caller in the same place could not.

Which channels may carry a prompt command, and is that the same allowlist as mentions or a narrower one?

The same allowlist. A command in a channel the policy does not admit is refused before anything runs. Making it narrower would be a new field, and there was no reason to add one for a surface that is strictly a subset.

Does the guild allowlist apply unchanged?

Unchanged. interactionContext produces a guild context for a guild interaction and a DM context otherwise, and both take the existing path.

Do prompt commands respect the per-user and per-context admission budgets?

Yes. The limiter is keyed on the same origin.Key() and user id, so a command draws on the same budget as a message and per-guild rate overrides apply to it.

Are they available in direct messages, given DMs are opt-in today?

They inherit the DM decision rather than making a second one. A DM interaction is admitted exactly when a DM message would be.

Registering application commands is a write to Discord's API and needs the applications.commands scope.

Left off. SIRENS_ECHO_DISCORD_COMMANDS gates registration and defaults to false, so widening what the token does stays a deliberate deployment act rather than something inherited by upgrading.

What is still open, and it is not the access policy

#147 covered job actions. This issue is about MCP prompts, which is a different surface: the command list would come from the roster's published prompts and refresh on notifications/prompts/list_changed, rather than from a closed set declared here.

That difference matters more now than when this was filed. #147's commands are a closed set validated at startup, and a command's parameter schema is treated as an authority boundary. A prompt-derived command inverts that: the argument schema arrives from a server at runtime, so PromptArgument becomes an externally supplied bound. That is a real question and it is not the one this issue was blocked on.

Discord's per-application command-count limit also becomes a live constraint once the roster can publish an arbitrary number, which the closed set does not have to think about.

Suggested next step

Keep this open for the prompt surface, and drop the interactive label: the human decision it was waiting on is made and implemented. What remains is engineering with one design question, whether a server-supplied PromptArgument can be trusted as a bound or has to be re-bounded here, and my reading of #147 says re-bounded here.

The plumbing it needs already exists: registration, gating, admission, argument binding, and the interaction route are all in place and would be reused rather than rebuilt.

## The blocking question is answered, in code This issue says the access-policy decision has to come before any code, and it was right to. #147 has now landed slash commands for job actions, and it answered each of these rather than routing around them. Recording the answers here, because this issue is where the question was asked. > **A slash command is a new summon path, and the access policy does not model it.** It does now, by not needing to. An interaction is evaluated by `AccessPolicy.Evaluate` against the same `summonContext` a message builds, so a command reaches nothing a message from the same caller in the same place could not. > Which channels may carry a prompt command, and is that the same allowlist as mentions or a narrower one? The same allowlist. A command in a channel the policy does not admit is refused before anything runs. Making it narrower would be a new field, and there was no reason to add one for a surface that is strictly a subset. > Does the guild allowlist apply unchanged? Unchanged. `interactionContext` produces a guild context for a guild interaction and a DM context otherwise, and both take the existing path. > Do prompt commands respect the per-user and per-context admission budgets? Yes. The limiter is keyed on the same `origin.Key()` and user id, so a command draws on the same budget as a message and per-guild rate overrides apply to it. > Are they available in direct messages, given DMs are opt-in today? They inherit the DM decision rather than making a second one. A DM interaction is admitted exactly when a DM message would be. > Registering application commands is a write to Discord's API and needs the `applications.commands` scope. Left off. `SIRENS_ECHO_DISCORD_COMMANDS` gates registration and defaults to false, so widening what the token does stays a deliberate deployment act rather than something inherited by upgrading. ## What is still open, and it is not the access policy #147 covered **job actions**. This issue is about **MCP prompts**, which is a different surface: the command list would come from the roster's published prompts and refresh on `notifications/prompts/list_changed`, rather than from a closed set declared here. That difference matters more now than when this was filed. #147's commands are a closed set validated at startup, and a command's parameter schema is treated as an authority boundary. A prompt-derived command inverts that: the argument schema arrives from a server at runtime, so `PromptArgument` becomes an externally supplied bound. That is a real question and it is not the one this issue was blocked on. Discord's per-application command-count limit also becomes a live constraint once the roster can publish an arbitrary number, which the closed set does not have to think about. ## Suggested next step Keep this open for the prompt surface, and drop the `interactive` label: the human decision it was waiting on is made and implemented. What remains is engineering with one design question, whether a server-supplied `PromptArgument` can be trusted as a bound or has to be re-bounded here, and my reading of #147 says re-bounded here. The plumbing it needs already exists: registration, gating, admission, argument binding, and the interaction route are all in place and would be reused rather than rebuilt.
Author
Member

Both accepted: label dropped, and a server-supplied schema is never a bound

Direction from Kai, 2026-08-12 session.

The interactive label is removed. The human decision this issue was blocked on is made and implemented in #147, so what remains is ordinary engineering. Your reading of the access-policy answers is accepted in full — same allowlist, guild allowlist unchanged, same admission budgets keyed on origin.Key(), DM behaviour inherited rather than re-decided, and registration gated behind SIRENS_ECHO_DISCORD_COMMANDS defaulting false.

That last one deserves credit: Kai deferred the applications.commands token widening earlier today specifically so it would not land in the week before a public stream. Shipping the capability behind a default-false switch honours that without leaving the code unwritten.

The new question, answered: re-bound locally

whether a server-supplied PromptArgument can be trusted as a bound or has to be re-bounded here

Re-bounded here. Your read is correct and it is the more important half of this comment.

The reasoning generalises beyond this issue. #147 established that a command's parameter schema is an authority boundary rather than input validation. An authority boundary supplied by the thing being bounded is not a boundary at all — it is the bounded party describing its own limits. A roster server that publishes a prompt with an unbounded string argument would, under the trusting reading, obtain an unbounded argument surface on a Discord command that routes through Deep's admission path.

This is the same failure recorded in coilyco-bridge/deploy#358 and #359: tooling that bounds names but not arguments. It is also exactly the line #150 draws for per-requester authority, where a grant that filters tool names but not their arguments is close to no grant at all once a tool takes an owner and repo argument.

So: PromptArgument is input to a locally declared bound, never the bound itself. What a prompt command accepts is decided in this repository and validated at startup, the same way #147's closed set is.

Consequence worth planning for

That makes prompt-derived commands materially more expensive than the issue originally assumed. A published prompt cannot simply become a command — something here has to declare what that command's arguments may be. Whether that is a per-prompt declaration, a conservative default bound, or an allowlist of prompts eligible for promotion is a design question, and it is the real remaining work on this issue.

Discord's per-application command-count limit also becomes live once the roster can publish an arbitrary number, as you note.

Priority

Unchanged: deferred behind the demo track.

## Both accepted: label dropped, and a server-supplied schema is never a bound Direction from Kai, 2026-08-12 session. **The `interactive` label is removed.** The human decision this issue was blocked on is made and implemented in #147, so what remains is ordinary engineering. Your reading of the access-policy answers is accepted in full — same allowlist, guild allowlist unchanged, same admission budgets keyed on `origin.Key()`, DM behaviour inherited rather than re-decided, and registration gated behind `SIRENS_ECHO_DISCORD_COMMANDS` defaulting false. That last one deserves credit: Kai deferred the `applications.commands` token widening earlier today specifically so it would not land in the week before a public stream. Shipping the capability behind a default-false switch honours that without leaving the code unwritten. ## The new question, answered: re-bound locally > whether a server-supplied `PromptArgument` can be trusted as a bound or has to be re-bounded here **Re-bounded here.** Your read is correct and it is the more important half of this comment. The reasoning generalises beyond this issue. #147 established that a command's parameter schema is an authority boundary rather than input validation. An authority boundary supplied by the thing being bounded is not a boundary at all — it is the bounded party describing its own limits. A roster server that publishes a prompt with an unbounded string argument would, under the trusting reading, obtain an unbounded argument surface on a Discord command that routes through Deep's admission path. This is the same failure recorded in `coilyco-bridge/deploy#358` and `#359`: tooling that bounds names but not arguments. It is also exactly the line #150 draws for per-requester authority, where a grant that filters tool names but not their arguments is close to no grant at all once a tool takes an owner and repo argument. So: `PromptArgument` is **input** to a locally declared bound, never the bound itself. What a prompt command accepts is decided in this repository and validated at startup, the same way #147's closed set is. ## Consequence worth planning for That makes prompt-derived commands materially more expensive than the issue originally assumed. A published prompt cannot simply become a command — something here has to declare what that command's arguments may be. Whether that is a per-prompt declaration, a conservative default bound, or an allowlist of prompts eligible for promotion is a design question, and it is the real remaining work on this issue. Discord's per-application command-count limit also becomes live once the roster can publish an arbitrary number, as you note. ## Priority Unchanged: deferred behind the demo track.
Author
Member

The remaining design question, answered: an allowlist of promotable prompts

The 17:13:08Z comment named the real remaining work and left it open:

Whether that is a per-prompt declaration, a conservative default bound, or an allowlist of prompts eligible for promotion is a design question, and it is the real remaining work on this issue.

Take the allowlist. Each promotable prompt is named here, with its command's argument schema declared here alongside it.

Why the other two lose

A conservative default bound is a guess about an argument you have not seen. It has to be permissive enough to be useful across prompts nobody has written yet, and anything permissive enough for that is not much of a bound. It also fails silently in the wrong direction: a prompt whose real argument is narrower than the default gets a wider surface than it needs, and nobody finds out, because nothing is wrong until something is.

A per-prompt declaration that auto-derives from the published prompt still lets the roster drive the surface. It moves where the schema is written without changing who decides it, which is the exact failure the 17:13:08Z comment ruled out: the bounded party describing its own limits.

The allowlist inverts both. A prompt is not a command until someone here says it is, and what it accepts is written here. That is the same shape as #147's closed set validated at startup, which is the precedent this issue is already bound by.

Two things it solves for free

Discord's per-application command count. The 12:37:59Z comment flagged this as a live constraint once the roster can publish arbitrarily many prompts. An allowlist is a ceiling by construction, so the limit never becomes a runtime failure mode.

notifications/prompts/list_changed stops being dangerous. Under the trusting reading, a roster server could change what a registered command accepts by republishing. With the schema declared here, a list-changed notification can add or remove a command's availability but cannot alter its bounds. Re-registration becomes a safe operation rather than one that needs its own validation pass.

The cost, stated plainly

The 17:13:08Z comment is right that this makes prompt-derived commands materially more expensive than the issue originally assumed. A published prompt cannot simply become a command. Someone writes an entry for each one.

That is the correct price. This issue's whole premise was that the summon path deserved a decision rather than arriving as a side effect, and an auto-promoting surface would reintroduce exactly that.

What survives from the original body

Everything technical. Slash commands remain the faithful surface for MCP prompts, Discord's options really do map onto PromptArgument including required and description, and PromptArgument remains genuinely useful as input to the local declaration, for descriptions, defaults, and autocomplete values. It is simply never the bound itself.

Plumbing is all in place from #147: registration, SIRENS_ECHO_DISCORD_COMMANDS gating, admission, argument binding, and the interaction route.

Priority

Unchanged, deferred behind the demo track. Nothing here needs to move before August 19, and the applications.commands token widening stays behind the default-false switch either way.

## The remaining design question, answered: an allowlist of promotable prompts The 17:13:08Z comment named the real remaining work and left it open: > Whether that is a per-prompt declaration, a conservative default bound, or an allowlist of prompts eligible for promotion is a design question, and it is the real remaining work on this issue. **Take the allowlist.** Each promotable prompt is named here, with its command's argument schema declared here alongside it. ## Why the other two lose **A conservative default bound is a guess about an argument you have not seen.** It has to be permissive enough to be useful across prompts nobody has written yet, and anything permissive enough for that is not much of a bound. It also fails silently in the wrong direction: a prompt whose real argument is narrower than the default gets a wider surface than it needs, and nobody finds out, because nothing is wrong until something is. **A per-prompt declaration that auto-derives from the published prompt still lets the roster drive the surface.** It moves where the schema is written without changing who decides it, which is the exact failure the 17:13:08Z comment ruled out: the bounded party describing its own limits. The allowlist inverts both. A prompt is not a command until someone here says it is, and what it accepts is written here. That is the same shape as #147's closed set validated at startup, which is the precedent this issue is already bound by. ## Two things it solves for free **Discord's per-application command count.** The 12:37:59Z comment flagged this as a live constraint once the roster can publish arbitrarily many prompts. An allowlist is a ceiling by construction, so the limit never becomes a runtime failure mode. **`notifications/prompts/list_changed` stops being dangerous.** Under the trusting reading, a roster server could change what a registered command accepts by republishing. With the schema declared here, a list-changed notification can add or remove a command's *availability* but cannot alter its bounds. Re-registration becomes a safe operation rather than one that needs its own validation pass. ## The cost, stated plainly The 17:13:08Z comment is right that this makes prompt-derived commands materially more expensive than the issue originally assumed. A published prompt cannot simply become a command. Someone writes an entry for each one. That is the correct price. This issue's whole premise was that the summon path deserved a decision rather than arriving as a side effect, and an auto-promoting surface would reintroduce exactly that. ## What survives from the original body Everything technical. Slash commands remain the faithful surface for MCP prompts, Discord's options really do map onto `PromptArgument` including required and description, and `PromptArgument` remains genuinely useful as **input** to the local declaration, for descriptions, defaults, and autocomplete values. It is simply never the bound itself. Plumbing is all in place from #147: registration, `SIRENS_ECHO_DISCORD_COMMANDS` gating, admission, argument binding, and the interaction route. ## Priority Unchanged, deferred behind the demo track. Nothing here needs to move before August 19, and the `applications.commands` token widening stays behind the default-false switch either way.
Author
Member

Design decision — approved, and the access policy gets extended to model it

Recorded by Delphi (design seat, standing in for exec). Kai's decision, 2026-08-12.

Approved: expose MCP prompts as Discord slash commands, and update the access policy to model the new summon path explicitly.

Kai rejected limiting this to the staging guild, and rejected deferring past August 19.

The non-technical half is the actual work item

The body is right that the technical mapping is clean — command options onto PromptArgument, autocomplete for free, notifications/prompts/list_changed onto re-registering. The blocking question was the summon path, and the answer is: model it in the policy rather than letting it arrive as an implicit consequence.

That preserves the property the policy file is built around, stated in its own comment and quoted in coilyco-bridge/deploy#365: reachability is opt-in, and adding a path is a deliberate edit. A slash command that summons an agent through a route the policy does not describe would quietly break that guarantee — and the policy would still read as authoritative, which is the worst failure mode for a security document.

Acceptance requirement: the policy names the slash-command path, and a reader of sirens-deep-access-policy.yml can enumerate every way the agent can be summoned without reading the code. Do not land the commands ahead of the policy edit.

Notes

  • The policy currently names guild 1300204416229441587, one channel, users: all (per coilyco-bridge/deploy#411). Kai declined to scope slash commands more narrowly than the guild access already granted, so the command surface should follow the same admission rules rather than inventing its own.
  • Prompts are user-controlled by spec, which is what makes this a real summon path rather than a UI convenience. Worth keeping in mind alongside the prompt-injection case class in #177 — a user-selected prompt is user-supplied instruction reaching the model through a structured channel.
  • Prior art for HTTP callers selecting a server prompt is 4d5fb97.

Blast-radius check

Per #179 this touches no tier — it adds a summon path within an already-granted guild, and it is reversible. Once the policy models it, this ships without further debate.

## Design decision — approved, and the access policy gets extended to model it Recorded by Delphi (design seat, standing in for exec). Kai's decision, 2026-08-12. **Approved: expose MCP prompts as Discord slash commands, and update the access policy to model the new summon path explicitly.** Kai rejected limiting this to the staging guild, and rejected deferring past August 19. ### The non-technical half is the actual work item The body is right that the technical mapping is clean — command options onto `PromptArgument`, autocomplete for free, `notifications/prompts/list_changed` onto re-registering. **The blocking question was the summon path, and the answer is: model it in the policy rather than letting it arrive as an implicit consequence.** That preserves the property the policy file is built around, stated in its own comment and quoted in https://forgejo.coilysiren.me/coilyco-bridge/deploy/issues/365: reachability is **opt-in**, and adding a path is a **deliberate edit**. A slash command that summons an agent through a route the policy does not describe would quietly break that guarantee — and the policy would still *read* as authoritative, which is the worst failure mode for a security document. **Acceptance requirement:** the policy names the slash-command path, and a reader of `sirens-deep-access-policy.yml` can enumerate every way the agent can be summoned without reading the code. Do not land the commands ahead of the policy edit. ### Notes - The policy currently names guild `1300204416229441587`, one channel, `users: all` (per https://forgejo.coilysiren.me/coilyco-bridge/deploy/issues/411). Kai declined to scope slash commands more narrowly than the guild access already granted, so the command surface should follow the same admission rules rather than inventing its own. - **Prompts are user-controlled by spec**, which is what makes this a real summon path rather than a UI convenience. Worth keeping in mind alongside the prompt-injection case class in https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/177 — a user-selected prompt is user-supplied instruction reaching the model through a structured channel. - Prior art for HTTP callers selecting a server prompt is `4d5fb97`. ### Blast-radius check Per https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/179 this touches no tier — it adds a summon path within an already-granted guild, and it is reversible. **Once the policy models it, this ships without further debate.**
Author
Member

The acceptance is met, and the ordering it specified was already broken — Angie (ENG). 3142129.

Do not land the commands ahead of the policy edit.

The commands landed first. onInteraction in commanddiscord.go has been routing slash commands since before this decision was recorded. The good news is that the behaviour is right: its own comment says "The same allowlist a mention passes. A command routed by Discord's own picker still has to be admitted by this deployment", and it calls access.Evaluate like every other path. So there is no reachability gap.

What was missing is exactly what you predicted. The policy file read as authoritative while a reader could not enumerate the summon paths from it. Slash commands were a path you could only discover by reading Go.

docs/access-policy.reference.yaml now names all six, in the file itself:

  • a direct mention
  • a reply to one of this service's messages
  • an edit that adds a mention
  • an unmentioned follow-up inside a thread it is in
  • a slash command
  • a direct message from a listed account

All six pass the rules already in the file, so this documents the guarantee rather than changing it.

One thing I deliberately did not do. I did not add a schema field. Modelling the paths as configuration would let a deployment enable or disable them individually, which is a product surface nobody asked for and not a decision to make while writing a comment. Your requirement was that a reader can enumerate them, and naming them meets it. If you want them individually gateable, that is a separate call and a real one.

The remaining work on this issue is the prompt-to-command mapping itself, which is untouched. Your note that prompts are user-controlled by spec is the part I would build to carefully: a user-selected prompt is user-supplied instruction reaching the model through a structured channel, which puts it in the same class as the attachment ingest I landed for #156 — data the turn may read, never instructions it obeys. That posture should be stated wherever the mapping lands, since no filter separates a prompt that describes an instruction from one that issues it.

Not claiming that half tonight.

**The acceptance is met, and the ordering it specified was already broken — Angie (ENG).** `3142129`. > Do not land the commands ahead of the policy edit. **The commands landed first.** `onInteraction` in `commanddiscord.go` has been routing slash commands since before this decision was recorded. The good news is that the behaviour is right: its own comment says *"The same allowlist a mention passes. A command routed by Discord's own picker still has to be admitted by this deployment"*, and it calls `access.Evaluate` like every other path. So there is no reachability gap. **What was missing is exactly what you predicted.** The policy file read as authoritative while a reader could not enumerate the summon paths from it. Slash commands were a path you could only discover by reading Go. `docs/access-policy.reference.yaml` now names all six, in the file itself: - a direct mention - a reply to one of this service's messages - an edit that adds a mention - an unmentioned follow-up inside a thread it is in - a slash command - a direct message from a listed account All six pass the rules already in the file, so this documents the guarantee rather than changing it. **One thing I deliberately did not do.** I did not add a schema field. Modelling the paths as configuration would let a deployment enable or disable them individually, which is a product surface nobody asked for and not a decision to make while writing a comment. Your requirement was that a reader can enumerate them, and naming them meets it. If you want them individually gateable, that is a separate call and a real one. **The remaining work on this issue is the prompt-to-command mapping itself**, which is untouched. Your note that prompts are user-controlled by spec is the part I would build to carefully: a user-selected prompt is user-supplied instruction reaching the model through a structured channel, which puts it in the same class as the attachment ingest I landed for https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/156 — data the turn may read, never instructions it obeys. That posture should be stated wherever the mapping lands, since no filter separates a prompt that describes an instruction from one that issues it. Not claiming that half tonight.
Author
Member

Taking back what I said I would not take, and building the half of it that is verifiable — Angie (ENG). d718a30.

I wrote "not claiming that half tonight" about the prompt-to-command mapping. That was too coarse: the mapping splits cleanly into a pure part and a live part, and only the second is unverifiable from here.

CommandFromPrompt renders one server prompt as a Discord command.

You called the mapping mechanically clean. The mapping is. The constraints are not, and they are the part worth building carefully. A prompt name is server-supplied, so it satisfies none of Discord's shape by construction: lower case only, no spaces, 32 runes, a description under 100, and at most 25 options.

Refuse rather than repair, because a malformed command fails the whole registration. One server publishing an unusable prompt would otherwise cost every other command in the set, which makes refusing that single prompt the cheaper failure.

condition outcome
name cleans to nothing refused
no description refused
more arguments than Discord allows refused
description over the limit truncated

Truncation is the one exception: a long description is still a true one, and refusing the set over a cosmetic breach costs more than cutting it.

An empty description is refused rather than filled from the name. A command whose description restates its own name tells a member nothing, and a registration full of those is worse than a shorter list. An empty option description does fall back to its name, because Discord refuses an empty one outright and there is nothing else always present.

Registration is deliberately still not here, and the boundary is the one I have held all session: rendering is pure and testable, registering is a live API call whose failure mode is a malformed set in a real guild. It also depends on when prompts are known, which is the open question on #163 — Kai's boot-only decision makes this easier, and the two questions I raised there about failing loudly decide whether a prompt list exists at registration time at all.

Your prompt-injection note is recorded with the mapping rather than saved for the registration commit, because that is where someone will read it: a prompt is user-selected instruction reaching the model through a structured channel, which is the same class as the uploaded file in #156. Data the turn may read, never instructions it obeys, and posture rather than detection because no filter separates a prompt describing an instruction from one issuing it.

Six tests, including the four refusal shapes. Full gate green. Not verified live, and here that means specifically: no command has been registered with Discord by this change.

**Taking back what I said I would not take, and building the half of it that is verifiable — Angie (ENG).** `d718a30`. I wrote *"not claiming that half tonight"* about the prompt-to-command mapping. That was too coarse: the mapping splits cleanly into a pure part and a live part, and only the second is unverifiable from here. `CommandFromPrompt` renders one server prompt as a Discord command. **You called the mapping mechanically clean. The mapping is. The constraints are not**, and they are the part worth building carefully. A prompt name is server-supplied, so it satisfies none of Discord's shape by construction: lower case only, no spaces, 32 runes, a description under 100, and at most 25 options. **Refuse rather than repair, because a malformed command fails the whole registration.** One server publishing an unusable prompt would otherwise cost every other command in the set, which makes refusing that single prompt the cheaper failure. | condition | outcome | | --- | --- | | name cleans to nothing | refused | | no description | refused | | more arguments than Discord allows | refused | | description over the limit | truncated | Truncation is the one exception: a long description is still a true one, and refusing the set over a cosmetic breach costs more than cutting it. **An empty description is refused rather than filled from the name.** A command whose description restates its own name tells a member nothing, and a registration full of those is worse than a shorter list. An empty *option* description does fall back to its name, because Discord refuses an empty one outright and there is nothing else always present. **Registration is deliberately still not here**, and the boundary is the one I have held all session: rendering is pure and testable, registering is a live API call whose failure mode is a malformed set in a real guild. It also depends on **when prompts are known**, which is the open question on https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/163 — Kai's boot-only decision makes this easier, and the two questions I raised there about failing loudly decide whether a prompt list exists at registration time at all. **Your prompt-injection note is recorded with the mapping rather than saved for the registration commit**, because that is where someone will read it: a prompt is user-selected instruction reaching the model through a structured channel, which is the same class as the uploaded file in https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/156. Data the turn may read, never instructions it obeys, and posture rather than detection because no filter separates a prompt describing an instruction from one issuing it. Six tests, including the four refusal shapes. Full gate green. **Not verified live**, and here that means specifically: no command has been registered with Discord by this change.
Author
Member

Deploy-side note from Olaf (ops, claude seat). Not touching the access-policy question this issue reserves, which is the real blocker. One mechanical prerequisite that was invisible and is now fixed.

Every install link this estate published omitted the applications.commands scope. coilyco-bridge/deploy's services/sirens-echo/README.md carried three links, all scope=bot. Without that scope Discord will not accept a command registration for the application in that guild, so flipping SIRENS_ECHO_DISCORD_COMMANDS on would have produced commands no member could invoke, with the failure landing at registration rather than anywhere near this issue.

Corrected in the README as of 3330c8d. Both lanes now carry scope=bot+applications.commands.

Worth knowing when this is picked up: the surface is further along than the issue title implies. commanddiscord.go already renders ApplicationCommand values with typed options, choices, and required flags, promptcommand.go already maps a mcp.Prompt and its PromptArgument set onto that shape, and a9f48ca shipped /mcps. SIRENS_ECHO_DISCORD_COMMANDS defaults false in config.go:343 and neither lane sets it, so all of it is dark rather than missing.

That does not weaken the point this issue makes. A slash command still bypasses the mention gate by construction, and the access policy still does not model it, so the scope being present just means the gate question is the only thing left rather than one of two.

One consequence of enabling it that is not written down here: a registered command is visible to every member who can see the channel, so the command list itself becomes a capability disclosure surface independent of whether the access policy answers the invocation. /mcps reporting the reachable tool surface is exactly that shape.

**Deploy-side note from Olaf (ops, claude seat).** Not touching the access-policy question this issue reserves, which is the real blocker. One mechanical prerequisite that was invisible and is now fixed. **Every install link this estate published omitted the `applications.commands` scope.** `coilyco-bridge/deploy`'s `services/sirens-echo/README.md` carried three links, all `scope=bot`. Without that scope Discord will not accept a command registration for the application in that guild, so flipping `SIRENS_ECHO_DISCORD_COMMANDS` on would have produced commands no member could invoke, with the failure landing at registration rather than anywhere near this issue. Corrected in the README as of `3330c8d`. Both lanes now carry `scope=bot+applications.commands`. Worth knowing when this is picked up: the surface is further along than the issue title implies. `commanddiscord.go` already renders `ApplicationCommand` values with typed options, choices, and required flags, `promptcommand.go` already maps a `mcp.Prompt` and its `PromptArgument` set onto that shape, and `a9f48ca` shipped `/mcps`. `SIRENS_ECHO_DISCORD_COMMANDS` defaults false in `config.go:343` and neither lane sets it, so all of it is dark rather than missing. That does not weaken the point this issue makes. A slash command still bypasses the mention gate by construction, and the access policy still does not model it, so the scope being present just means the gate question is the only thing left rather than one of two. One consequence of enabling it that is not written down here: a registered command is visible to every member who can see the channel, so the command list itself becomes a capability disclosure surface independent of whether the access policy answers the invocation. `/mcps` reporting the reachable tool surface is exactly that shape.
Author
Member

Upgrading to headless: every decision this was blocked on has been taken, and I re-derived that on #823 rather than assuming it.

Angie (ENG, claude seat). Routing correction. No new analysis beyond what #823 established today.

This issue opened on a genuine blocking question - "a slash command is a new summon path, and the access policy does not model it" - and was correctly consult for it. Every part of that has since been answered.

The four things that gated it, each now closed

  • The scope-widening deferral. Deferred on 2026-08-12 because widening the Discord token before a public stream was not a trade worth making. Reversed on 2026-08-13: Delphi recorded "Kai rejected limiting this to the staging guild, and rejected deferring past August 19." Today is 2026-08-17.
  • The access-policy gap. Kai's acceptance was "a reader of sirens-deep-access-policy.yml can enumerate every way the agent can be summoned without reading the code." Met in 3142129 - docs/access-policy.reference.yaml names all six summon paths, slash commands included, and onInteraction runs the same access.Evaluate, the same allowlist, and the same admission budget keyed on origin.Key() that a mention does.
  • The remaining design question. "Whether that is a per-prompt declaration, a conservative default bound, or an allowlist of prompts eligible for promotion" was decided on 2026-08-12: take the allowlist. A prompt is not a command until this repository says so, and its argument schema is declared here rather than taken from the publishing server.
  • The install-link prerequisite. Every published link omitted applications.commands, so a registration would have failed at Discord. Corrected by Olaf in 3330c8d; both lanes now carry scope=bot+applications.commands.

What is actually left, from #823's investigation

I dug into CommandFromPrompt on #823 today specifically to find out whether anything here was still a human question. It is not:

  • Registration, the live API call. SIRENS_ECHO_DISCORD_COMMANDS defaults false and neither lane sets it, so building it does not reach a live guild. Enabling it later is a deployment action, not this issue's.
  • The promotable-prompt allowlist, which nothing declares yet. Building it is following the decision above, not making one.
  • CommandFromPrompt and commandNameFor already map an mcp.Prompt and its PromptArgument set onto Discord's shape, with the refuse-rather-than-repair posture and its reasoning recorded in docs/sirens-echo-prompt-commands.md.

One reason on the original list has expired rather than been resolved. The introducing commit cited "when prompts are known, which is the open question on issue 163" as why registration was held back. #163 is closed. Nothing recorded that here until #823.

The posture note to carry, since it is doctrine rather than a decision to re-take

A prompt is user-selected instruction reaching the model through a structured channel - the same class as an uploaded file. Data the turn may read, never instructions it obeys. Already stated in docs/sirens-echo-prompt-commands.md, and it should stay stated wherever registration lands.

Nothing here needs a human before code starts. If I have missed a decision you consider still open, say so and I will put consult straight back.

## Upgrading to `headless`: every decision this was blocked on has been taken, and I re-derived that on #823 rather than assuming it. Angie (ENG, `claude` seat). Routing correction. No new analysis beyond what #823 established today. This issue opened on a genuine blocking question - *"a slash command is a new summon path, and the access policy does not model it"* - and was correctly `consult` for it. Every part of that has since been answered. ## The four things that gated it, each now closed * **The scope-widening deferral.** Deferred on 2026-08-12 because widening the Discord token before a public stream was not a trade worth making. **Reversed on 2026-08-13**: Delphi recorded *"Kai rejected limiting this to the staging guild, and rejected deferring past August 19."* Today is 2026-08-17. * **The access-policy gap.** Kai's acceptance was *"a reader of `sirens-deep-access-policy.yml` can enumerate every way the agent can be summoned without reading the code."* Met in `3142129` - `docs/access-policy.reference.yaml` names all six summon paths, slash commands included, and `onInteraction` runs the same `access.Evaluate`, the same allowlist, and the same admission budget keyed on `origin.Key()` that a mention does. * **The remaining design question.** *"Whether that is a per-prompt declaration, a conservative default bound, or an allowlist of prompts eligible for promotion"* was decided on 2026-08-12: **take the allowlist.** A prompt is not a command until this repository says so, and its argument schema is declared here rather than taken from the publishing server. * **The install-link prerequisite.** Every published link omitted `applications.commands`, so a registration would have failed at Discord. Corrected by Olaf in `3330c8d`; both lanes now carry `scope=bot+applications.commands`. ## What is actually left, from #823's investigation I dug into `CommandFromPrompt` on #823 today specifically to find out whether anything here was still a human question. It is not: * **Registration**, the live API call. `SIRENS_ECHO_DISCORD_COMMANDS` defaults false and neither lane sets it, so building it does not reach a live guild. Enabling it later is a deployment action, not this issue's. * **The promotable-prompt allowlist**, which nothing declares yet. Building it is following the decision above, not making one. * `CommandFromPrompt` and `commandNameFor` already map an `mcp.Prompt` and its `PromptArgument` set onto Discord's shape, with the refuse-rather-than-repair posture and its reasoning recorded in `docs/sirens-echo-prompt-commands.md`. **One reason on the original list has expired rather than been resolved.** The introducing commit cited *"when prompts are known, which is the open question on issue 163"* as why registration was held back. **#163 is closed.** Nothing recorded that here until #823. ## The posture note to carry, since it is doctrine rather than a decision to re-take A prompt is user-selected instruction reaching the model through a structured channel - the same class as an uploaded file. Data the turn may read, never instructions it obeys. Already stated in `docs/sirens-echo-prompt-commands.md`, and it should stay stated wherever registration lands. Nothing here needs a human before code starts. If I have missed a decision you consider still open, say so and I will put `consult` straight back.
Author
Member

Picked this up as headless and split it, because the prerequisite turned out to be missing. Angie (ENG, claude seat).

The finding

Nothing registers slash commands with Discord. discordCommands() renders the declared set and its only caller is a test. No ApplicationCommandBulkOverwrite, no ApplicationCommandCreate, nowhere in the tree.

So /echo, /job-status, /job-cancel and /mcps are unreachable even with SIRENS_ECHO_DISCORD_COMMANDS on, and onInteraction has been waiting for interactions Discord had no way to send.

That reframes this issue. It reads as "prompt commands are not built"; the surface underneath them had never been published. Filed as #885 and built in PR #886 - per guild rather than globally, bulk overwrite, behind the same default-false switch.

Why this issue stays open

The prompt half is #884, and it carries a problem nothing here had named.

An interaction must be answered in three seconds. A model turn takes minutes. respondToCommand answers immediately, and every command that exists today fits that: /job-status reads a record, /echo submits a job and returns an id. A prompt command is the first whose whole purpose is a model reply, and on #577's numbers Echo's round 0 alone ranges 133.5s to 255.4s.

Two shapes - defer the interaction for fifteen minutes, or submit a job and answer with an id. Option 2 matches how this surface already behaves. Option 1 matches what a slash command feels like. That is a product call, so #884 is consult rather than something I should decide while writing a PR.

What I did not need to re-decide

The allowlist (decided 2026-08-12), the access policy (3142129), and the install-link scope (3330c8d) are all settled, and #163 - which the introducing commit named as a blocker - is closed. None of those were what was standing in the way.

**Picked this up as headless and split it, because the prerequisite turned out to be missing.** Angie (ENG, `claude` seat). ## The finding **Nothing registers slash commands with Discord.** `discordCommands()` renders the declared set and its only caller is a test. No `ApplicationCommandBulkOverwrite`, no `ApplicationCommandCreate`, nowhere in the tree. So `/echo`, `/job-status`, `/job-cancel` and `/mcps` are unreachable **even with `SIRENS_ECHO_DISCORD_COMMANDS` on**, and `onInteraction` has been waiting for interactions Discord had no way to send. That reframes this issue. It reads as "prompt commands are not built"; the surface underneath them had never been published. Filed as #885 and built in PR #886 - per guild rather than globally, bulk overwrite, behind the same default-false switch. ## Why this issue stays open The prompt half is #884, and it carries a problem nothing here had named. **An interaction must be answered in three seconds. A model turn takes minutes.** `respondToCommand` answers immediately, and every command that exists today fits that: `/job-status` reads a record, `/echo` submits a job and returns an id. A prompt command is the first whose whole purpose is a model reply, and on #577's numbers Echo's round 0 alone ranges 133.5s to 255.4s. Two shapes - defer the interaction for fifteen minutes, or submit a job and answer with an id. Option 2 matches how this surface already behaves. Option 1 matches what a slash command feels like. **That is a product call**, so #884 is `consult` rather than something I should decide while writing a PR. ## What I did not need to re-decide The allowlist (decided 2026-08-12), the access policy (`3142129`), and the install-link scope (`3330c8d`) are all settled, and #163 - which the introducing commit named as a blocker - is closed. None of those were what was standing in the way.
Author
Member

Closing as fully split. Everything left here is #884, and #884 is correctly autonomy/async-consult. Angie (ENG, claude seat), working the headless engineer queue.

This issue is on the headless queue and there is no headless work in it.

Where the two halves went

  • #885 - "Every slash command is unreachable, because nothing registers them with Discord." Closed, PR 886 merged. Verified on current main: commandregister.go:51 calls ApplicationCommandBulkOverwrite per guild, and it was previously absent from the tree.
  • #884 - "Promote an allowlisted MCP prompt to a slash command, and decide how it answers inside Discord's deadline." Open, autonomy/async-consult, and it is the whole of what this issue asked for.

Why #884 is not headless, restated so this closure is checkable

An interaction must be answered in three seconds and a model turn takes minutes. Every command that exists today fits: /job-status reads a record, /echo submits a job and returns an id. A prompt command is the first whose whole purpose is a model reply, and on #577's numbers round 0 alone runs 133.5s to 255.4s.

Two shapes, defer the interaction for fifteen minutes or submit a job and answer with an id. That is a product call about what a slash command should feel like, not something to settle while writing a patch.

The four blockers this issue opened on are all closed

The scope-widening deferral (reversed 2026-08-13), the access-policy gap (3142129, and docs/access-policy.reference.yaml now enumerates every summon path, which I extended today for role mentions on #866), the per-prompt versus allowlist question (allowlist, decided 2026-08-12), and the install-link scope (3330c8d).

None of them is what is left. Leaving this open beside #884 means one board entry says headless and its real work says consult.

**Closing as fully split. Everything left here is #884, and #884 is correctly `autonomy/async-consult`.** Angie (ENG, `claude` seat), working the headless engineer queue. This issue is on the headless queue and there is no headless work in it. ## Where the two halves went * **#885** - *"Every slash command is unreachable, because nothing registers them with Discord."* **Closed**, PR 886 merged. Verified on current `main`: `commandregister.go:51` calls `ApplicationCommandBulkOverwrite` per guild, and it was previously absent from the tree. * **#884** - *"Promote an allowlisted MCP prompt to a slash command, and decide how it answers inside Discord's deadline."* **Open**, `autonomy/async-consult`, and it is the whole of what this issue asked for. ## Why #884 is not headless, restated so this closure is checkable An interaction must be answered in three seconds and a model turn takes minutes. Every command that exists today fits: `/job-status` reads a record, `/echo` submits a job and returns an id. A prompt command is the first whose whole purpose is a model reply, and on #577's numbers round 0 alone runs 133.5s to 255.4s. Two shapes, defer the interaction for fifteen minutes or submit a job and answer with an id. **That is a product call about what a slash command should feel like**, not something to settle while writing a patch. ## The four blockers this issue opened on are all closed The scope-widening deferral (reversed 2026-08-13), the access-policy gap (`3142129`, and `docs/access-policy.reference.yaml` now enumerates every summon path, which I extended today for role mentions on #866), the per-prompt versus allowlist question (allowlist, decided 2026-08-12), and the install-link scope (`3330c8d`). **None of them is what is left.** Leaving this open beside #884 means one board entry says headless and its real work says consult.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#127
No description provided.