Skip to main content
One function on CapabilityWorker. You call it, it plays a track and blocks until playback genuinely ends, and it tells you why it ended.
You do not manage the audio pipeline, the device buffer, music mode, or the recovery afterwards. All of that is inside the call. What you do own is deciding what happens next based on outcome — and that’s the whole job. Speaking is one-shot: you hand speak() a sentence and it comes back when the sentence is over. Music isn’t like that. A track is minutes long, the user can interrupt it halfway with their voice, and after that interruption the device has to be walked back to a state where your next sentence is actually audible. This function is one await that covers a whole listening session and returns a decision point.

stream_music_from_url()

Returns {"outcome", "position", "sent", "byte_offset"}.
duration_seconds is the full track length, not what’s left — the call subtracts start_seconds for you. Bad values don’t crash anything, they just make the reported position inaccurate, so a later resume starts in the wrong place. When you don’t know the real length, pass 0 and take the estimate.

During playback

While a stream is live the Ability is in music mode. Normal conversation is suspended: your Ability is not receiving transcriptions, and you must not call speak() or run_io_loop(). Two things get through, and they’re the two the user actually needs. You never handle those events yourself. The platform raises them, the function is watching for them, and it converts them into a return value — typically within a few tenths of a second of the user speaking. From your side a pause is simply “the await came back, and outcome says paused.” However a call ends — finished, paused, stopped, even error — the client is audible again and the platform is listening when it returns. You can speak() or open an IO loop on the very next line. That’s true of the error path too, so you never need cleanup of your own.

Reading outcome

position is seconds heard (including start_seconds), and it’s the halt instant — not “when cleanup finished” — so feeding it into a resume lands where the user left off. sent is bytes delivered, useful for logs.

Resume is just calling again

There is no separate resume function. A resume is the same call, with two values carried over from the previous result:
Which makes the natural shape of playback a loop: play, and if it came back "paused", ask the user what they want and either loop or leave.

The five rules that live in your code, not in the function

  1. Re-resolve the url on every pass. Signed CDN links expire while the user sits paused; the loop above pays one cheap call to never hit that.
  2. Only "paused" loops. finished, stopped, error and unplayable all leave — a loop that retries on error spins.
  3. An unclear pause reply should stop, not resume. A user who asked for silence must not get the track back because a reply didn’t parse.
  4. announce on the first pass only, or every resume re-announces the title.
  5. Never speak() while a stream is live. Speak before the call or after it returns.

Starting point

The music-template is a complete Ability with the loop above already written and the outcome routing already correct — see Example 4: Music Playback for the full file. Two methods are yours to fill in:
duration is the FULL track length in seconds, and title is what gets announced before playback. Everything else in that file — the pause menu, the outcome branch, the resume_normal_flow() on exit — can be left alone.
Reach for play_from_audio_file() instead for short audio bundled with your Ability — a chime, a sound effect, a recorded line. stream_music_from_url() is for the minutes-long, interruptible case.