🧠⚡ Discord Responsive Interview Embed — idiot-proof genius cheat sheet

Goal: one Discord message becomes a tiny wizard: user clicks/buttons/types → bot ACKs instantly → message evolves in place → Amy gets clean structured answers → no dead air, no spam, no broken interactions.

🏷️ Naming — Ompcord vs Amy. Ompcord is the omp⇄Discord bridge (product/plugin); Amy is the bot persona it runs. You install Ompcord; Amy is who answers. Rename phases 1–5 are complete at docs/command/runtime/package level: package name ompcord, wrapper runtime ompcordd.mjs/ompcordd.service, and compatibility for amyd.mjs, /amy, /pi-discord-remote, legacy config, and ~/.omp/amy-sessions/. Repo directory stays pi-discord-amy until final filesystem cutover. See the rename plan.*

Default choice: use a normal embed + legacy message components. Use Components V2 only when you need layout primitives (Container, Section, Text Display) and accept that normal content/embeds are disabled for that message.

For the shared status/options surface behind these flows, see Amy SSOT Embed Dashboard.


✅ Live-send recipe that finally worked

The success pattern is not “describe an embed in chat.” It is: launch a short-lived Discord gateway process that posts a real message with embeds + components into the active Amy thread and keeps running long enough to handle component interactions.

Observed live proof (2026-06-10):

ItemValue
Thread1514275906170912769
Message1514313332268597429
Script~/pi-discord-amy/live-responsive-interview.mjs
BehaviorReal embed fields + select + multi-select + pagination buttons + busy buttons + Custom modal + Confirm/Cancel

Exact launch shape:

cd ~/pi-discord-amy
node live-responsive-interview.mjs <active-thread-id>

The script reads DISCORD_TOKEN / ALLOWED_USER_IDS from /home/usr/.config/amy/amyd.env unless env vars are already set. It prints only message/thread/session ids — never the token.

What made it succeed:

  1. Use the active Amy thread id from amyd.log or the current Discord URL.
  2. Send an actual Discord API payload: channel.send({ embeds: [embed], components }).
  3. Keep a gateway client alive for the interaction TTL; otherwise the message renders but buttons/selects are dead.
  4. Route every component by customId prefix (liveiv:<sessionId>:...) so this proof cannot steal normal Amy interactions.
  5. ACK first:
    • showModal() is the first response for Custom….
    • deferUpdate() is the first response for select/buttons/modal submit edits.
  6. Edit the same message after each answer: liveMessage.edit({ embeds: [embed()], components: components() }).
  7. Disable components on done/cancel/timeout so stale buttons do not remain clickable.

Minimal “do this again” checklist:

  • Confirm Amy thread id.
  • Run node --check live-responsive-interview.mjs.
  • Launch node live-responsive-interview.mjs <threadId>.
  • Verify console prints sent message=<id> thread=<id> session=<id>.
  • Click a select/button in Discord.
  • Confirm the same message edits in place.
  • Use Confirm Path or Cancel to disable controls.

🚨 Non-negotiable laws

LawWhy it matters
ACK every interaction within 3sMiss it and Discord invalidates the token; user sees “application did not respond”.
🕒 Token lives 15 min after ACKDefer/update fast, then edit/follow up during the 15-min window.
🧠 State lives server-sidecustom_id is max 100 chars; store only a lookup key in Discord.
🧵 One evolving messageInterview/dashboard edits in place; final answer can be separate.
🔐 Allow-list every clickGate chat, slash, buttons, selects, and modal submits.
🛑 Never run agent work before ACKdeferUpdate() / deferReply() first, expensive work second.
// ✅ correct: instant ACK, then work
await interaction.deferUpdate();
await saveAnswer(interaction);
await interaction.message.edit(renderNextQuestion(state));
 
// ❌ wrong: Discord token dies while the agent/tool runs
await runAgentFor30Seconds();
await interaction.update(renderNextQuestion(state));

🧩 Best UX pattern

idle → asking → answered → confirming → complete
          ↘ timeout / cancelled / failed

Render each question as:

🧠 Interview · Q2/7
What kind of result do you want?
 
Current answer: —
Progress: 2 / 7
Why this matters: Chooses implementation depth.
 
[Quick] [Balanced] [Deep] [Custom…]
[Back] [Skip] [Cancel]

Use the right input:

NeedBest component
2–4 single-choice optionsButtons
5–25 optionsString select
Multi-choiceString select with max_values > 1 + Submit
Freeform textModal opened by Custom…
Final commitConfirm / Edit / Cancel buttons

🧱 Minimal state contract

const session = {
  id: "short-random-id",
  threadId,
  messageId,
  userId,
  index: 0,
  status: "asking", // asking | confirming | complete | cancelled | timed_out | failed
  answers: {},
  startedAt: Date.now(),
  updatedAt: Date.now(),
};

custom_id should be tiny and routable:

iv:<sessionId>:pick:<questionId>:<optionId>
iv:<sessionId>:custom:<questionId>
iv:<sessionId>:back
iv:<sessionId>:skip
iv:<sessionId>:cancel

Do not put full JSON, prompts, secrets, or long labels inside custom_id.


🎛️ Button style rules

StyleUse for
PrimaryOne recommended/default path only
SecondaryNeutral alternatives (Back, Custom…)
SuccessSubmit, Confirm, complete action
DangerCancel, destructive/stop action
LinkExternal URL only; no interaction event

Copy rule: button labels should be outcomes, not vague commands.

Choose Deep, Custom answer, Confirm plan
OK, Yes, Option 1, Next maybe


📏 Hard Discord limits to memorize

LimitValue
Initial interaction ACK≤ 3 seconds
Interaction follow-up/edit token15 minutes
Message content2000 chars; split at ~1900
Buttons per action row≤ 5
Rows per classic component message≤ 5
Select options≤ 25
Button label≤ 80 chars; keep ~34–38 visible chars
Select label/value/description≤ 100 chars each
Select placeholder≤ 150 chars
custom_id1–100 chars
Modal title≤ 45 chars
Modal components1–5
Components V2 total components≤ 40
Embed description≤ 4096 chars
Embed field value≤ 1024 chars

🧠 Amy/omp integration

Current Amy pieces:

FileRole
~/pi-discord-amy/amyd.mjs / ompcordd.mjsDiscord daemon/runtime wrapper; thread chat + /amy//ompcord; spawns omp -p --mode json.
run.mjsJSONL stream parser + driveRun() ask loop.
ask.mjsDiscord picker/modal implementation; returns answers[].
dashboard.mjsOne evolving embed per agent run.
slash.mjs/amy + /ompcord status/new/say/cancel/stop; mutating commands defer first.

Headless ask bridge:

Agent needs input
→ emits ```amy-ask JSON block
→ run.mjs extracts questions
→ ask.mjs renders Discord components
→ user answers
→ answersToPrompt() feeds continuation with -c
→ agent continues

Stable answer kinds:

option · multi · custom · chat

Keep that contract stable so existing agent prompts/tools do not break.


🏆 Perfect interview behavior checklist

  • Posts first embed immediately: “Q1/N — choose one.”
  • Every click/select/modal submit is allow-listed.
  • Every interaction ACKs before storage, network, agent, or tool work.
  • Selected answer appears instantly in the same message.
  • Back/Edit works before final confirm.
  • Timeout disables stale components and explains how to resume.
  • Cancel disables components and marks state cancelled.
  • Partial answers are preserved on timeout/error.
  • Final confirm emits compact structured answers into the continuation prompt.
  • Long final prose is chunked safely; only last chunk mentions the user.
  • Errors are visible: no silent REST failures, no dead buttons.

🧯 Failure fixes

SymptomRoot cause → fix
“Application did not respond”Missed 3s ACK or daemon down → deferReply() / deferUpdate() first; verify process online.
Button does nothingMissing interactionCreate handler, wrong custom_id, stale message, or allow-list denied.
Modal never opensshowModal() was not the first response to that interaction.
Slash command invisibleBot missing applications.commands scope or commands not registered to guild.
Cannot create session threadMissing Create Public Threads / Send in Threads / View / Send permissions.
Select menu rejects optionsMore than 25 options or label/value/description >100 chars.
Components V2 message lost embed/contentExpected: V2 disables normal content and embeds; use Text Display/Container instead.
User clicks old questionCheck session id + status; reply ephemeral “This interview expired.”

🧪 Test matrix

TestExpected proof
Button choiceACK <3s, embed edits to next question.
Select + SubmitMulti answers saved in order; Submit disabled until selection.
Custom modalModal opens immediately; submit updates original message.
Back/EditPrevious answer reloads and can be replaced.
CancelComponents disabled, state cancelled, no agent continuation.
TimeoutComponents disabled, partial answers retained, resume path visible.
Denied userEphemeral allow-list warning; state unchanged.
Agent continuation[interactive answers] continuation prompt is generated; no amy-ask block leaks to user.

🧬 Golden implementation skeleton

client.on("interactionCreate", async (i) => {
  if (!isInterviewInteraction(i)) return;
  if (!isAllowed(i.user.id)) return i.reply({ content: "⛔ Not allowed.", ephemeral: true });
 
  const action = parseCustomId(i.customId);
  const state = sessions.get(action.sessionId);
  if (!state || state.status !== "asking") {
    return i.reply({ content: "⌛ This interview expired.", ephemeral: true });
  }
 
  if (action.type === "custom") {
    return i.showModal(buildCustomAnswerModal(state, action.questionId));
  }
 
  await i.deferUpdate();
  applyAction(state, action, i);
  await i.message.edit(renderInterview(state));
});

🔗 Source truth