Follow mode makes the bot track selected users as they move between Discord voice channels.

Follow users in voice

Use voice.followUsers when you want the Discord voice bot to stay with one or more known Discord users instead of joining a fixed channel at startup or waiting for /vc join.

{
  channels: {
    discord: {
      voice: {
        enabled: true,
        followUsersEnabled: true,
        followUsers: ["discord:123456789012345678"],
        allowedChannels: [
          {
            guildId: "123456789012345678",
            channelId: "234567890123456789",
          },
        ],
      },
    },
  },
}

Behavior:

Choose between the join modes:

Discord voice codec:

STT plus TTS pipeline:

Default agent-proxy voice-channel session example:

{
  channels: {
    discord: {
      voice: {
        enabled: true,
        model: "openai/gpt-6-astra",
        followUsersEnabled: true,
        followUsers: ["123456789012345678"],
        realtime: {
          provider: "openai",
          model: "gpt-realtime-2.1",
          speakerVoice: "cedar",
        },
      },
    },
  },
}

With no voice.agentSession block, each voice channel gets its own routed Apus session. For example, /vc join channel:234567890123456789 talks to the session for that Discord voice channel. The realtime model is only the voice front end; substantive requests are handed to the configured Apus agent. If the realtime model produces a final transcript without calling the consult tool, Apus forces the consult as a fallback so the default still behaves like talking to the agent.

Legacy STT plus TTS example:

{
  channels: {
    discord: {
      voice: {
        enabled: true,
        mode: "stt-tts",
        model: "openai/gpt-5.4-mini",
        tts: {
          provider: "openai",
          providers: {
            openai: {
              model: "gpt-4o-mini-tts",
              speakerVoice: "cedar",
            },
          },
        },
      },
    },
  },
}

Realtime bidi example:

{
  channels: {
    discord: {
      voice: {
        enabled: true,
        mode: "bidi",
        model: "openai/gpt-6-astra",
        realtime: {
          provider: "openai",
          model: "gpt-realtime-2.1",
          speakerVoice: "cedar",
          toolPolicy: "safe-read-only",
          consultPolicy: "always",
        },
      },
    },
  },
}

Voice as an extension of an existing Discord channel session:

{
  channels: {
    discord: {
      voice: {
        enabled: true,
        mode: "agent-proxy",
        model: "openai/gpt-6-astra",
        agentSession: {
          mode: "target",
          target: "channel:123456789012345678",
        },
        realtime: {
          provider: "openai",
          model: "gpt-realtime-2.1",
          speakerVoice: "cedar",
        },
      },
    },
  },
}

In agent-proxy mode the bot joins the configured voice channel, but Apus agent turns use the target channel's normal routed session and agent. The realtime voice session speaks the returned result back into the voice channel. The supervisor agent can still use normal message tools according to its tool policy, including sending a separate Discord message if that is the right action.

During a realtime voice call, ask the agent to list available voices or switch to one. The talk_voice tool changes the current room's voice and reconnects its realtime provider connections while preserving the Discord connection, conversation, and ongoing agent work. The agent confirms the change after the replacement is ready. If the switch fails, Apus keeps or restores the previous voice. If restoration also fails, it asks you to join voice again; accepted agent work can still finish. The selection applies to subsequent speakers in that room and lasts until the room disconnects; voice.realtime.speakerVoice remains the default for future calls. This requires an owner-authorized voice turn, agent delegation, and access to the talk_voice tool.

In-call changes require a provider voice catalog. Google Live supports the same talk_voice flow: Apus opens a fresh connection with the selected voice and keeps unfinished agent work with the Discord call. Answers waiting to be sent follow the replacement. A response already submitted to the previous connection may be interrupted by the switch and is not replayed, avoiding duplicate speech.

While a delegated Apus run is active, command-authorized Discord conversation transcripts are treated as live run control before starting another agent turn. Phrases such as "status", "cancel that", "use the smaller fix", or "when you're done also check tests" are classified as status, cancel, steering, or follow-up input for the active session. Status, cancel, accepted steering, and follow-up outcomes are spoken back into the voice channel so the caller knows whether Apus handled the request.

When Apus cancels a delegated consult, Discord records cancellation rather than a failure and does not play the generic error fallback. Matching late provider tool calls receive the same terminal cancellation instead of restarting the work. The voice session remains available for the next request; timeouts and genuine failures keep their normal error handling.

Useful target forms:

Echo-heavy OpenAI Realtime example:

{
  channels: {
    discord: {
      voice: {
        enabled: true,
        mode: "bidi",
        model: "openai/gpt-6-astra",
        realtime: {
          provider: "openai",
          model: "gpt-realtime-2.1",
          speakerVoice: "cedar",
          bargeIn: true,
          minBargeInAudioEndMs: 500,
          consultPolicy: "always",
          providers: {
            openai: {
              interruptResponseOnInputAudio: false,
            },
          },
        },
      },
    },
  },
}

Use this when the model hears its own Discord playback through an open mic, but you still want to interrupt it by speaking. Apus keeps OpenAI from auto-interrupting on raw input audio, while bargeIn: true lets Discord speaker-start events and already-active speaker audio cancel active realtime responses before the next captured turn reaches OpenAI. Very early barge-in signals with audioEndMs below minBargeInAudioEndMs are treated as likely echo/noise and ignored so the model does not cut off at the first playback frame.

Expected voice logs:

To debug cut-off audio, read the realtime voice logs as a timeline:

  1. realtime audio playback started means Discord has begun playing assistant audio. The bridge starts counting assistant output chunks, Discord PCM bytes, provider realtime bytes, and synthesized audio duration from this point.
  2. realtime speaker turn opened marks a Discord speaker becoming active. If playback is already active and bargeIn is enabled, this can be followed by barge-in detected source=speaker-start.
  3. realtime input audio started marks the first actual audio frame received for that speaker turn. outputActive=true or a nonzero outputAudioMs here means the mic is sending input while assistant playback is still active.
  4. barge-in detected source=active-speaker-audio means Apus saw live speaker audio while assistant playback was active. This is useful for distinguishing a real interruption from a Discord speaker-start event with no useful audio.
  5. barge-in requested reason=... means Apus asked the realtime provider to cancel or truncate the active response. outputAudioMs and playbackChunks describe generated audio; the subsequent audioEndMs truncation field records consumed audio for each native item. Discord measures local consumption from Opus packets prepared by AudioPlayer, at 20 ms granularity; it cannot measure remote listener playout.
  6. realtime audio playback stopped reason=... is the local Discord playback reset point. player-idle means Discord finished consuming the audio; provider response.done and encoder completion alone do not mean playback is finished. Other reasons include barge-in, provider-clear-audio, forced-agent-consult, stream-close, output-audio-overflow, and session-close.
  7. realtime speaker turn closed summarizes the captured input turn. chunks=0 or hasAudio=false means the speaker turn opened but no usable audio reached the realtime bridge. interruptedPlayback=true means that input turn overlapped assistant output and triggered barge-in logic.

Useful fields:

Common patterns:

Credentials are resolved per component: LLM route auth for voice.model, STT auth for tools.media.audio, TTS auth for tts/voice.tts, and realtime provider auth for voice.realtime.providers or the provider's normal auth config.