๐Ÿ’ฌ Interviews

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 windowDefer/update fast, then edit or follow up during Discordโ€™s 15-minute post-ACK 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