Building Claude Agent SDK background jobs with voice I/O...
Repeatable process for creating agentic background jobs with voice I/O and queue integration.
Use this workflow when building agents that:
RunningFifoQueuecosa-voice MCP toolsAgenticJobBase interface contractSlash Command: /lupin-new-claude-agent-sdk-voice-workflow
Reference Agents:
src/cosa/agents/deep_research/ - Research agent patternsrc/cosa/agents/podcast_generator/ - Audio generation pattern| Phase | Purpose | Output |
|---|---|---|
| Phase 0 | Interactive Discovery | Agent characteristics |
| Phase 1-2 | Skeletal Foundation | Basic agent structure |
| Phase 3 | Voice Notifications | cosa-voice integration |
| Phase 4 | Queue Integration | RunningFifoQueue hooks |
| Phase 5 | Testing | Validation and debugging |
| Phase 5b | Q&A Script | Notification Proxy profile for automated testing |
| Phase 5c | UI E2E Testing | Playwright browser tests (planned v0.1.6) |
Before creating files, answer:
pdf_summarizerps โ job IDs like ps-a1b2c3d4class OrchestratorState( Enum ):
# Active states
INITIALIZING = "initializing"
PROCESSING = "processing"
GENERATING = "generating"
# Waiting states (human-in-the-loop)
WAITING_APPROVAL = "waiting_approval"
# Terminal states
COMPLETED = "completed"
FAILED = "failed"
# Job lifecycle: set_job_id at start, clear in finally
voice_io.set_job_id( self.id_hash )
try:
# Progress update (MUST include queue_name="run")
await voice_io.notify( "Starting research phase", priority="low", queue_name="run" )
# Human-in-the-loop
response = ask_yes_no( "Approve this plan?", default="yes" )
# Completion
await voice_io.notify( "Agent completed successfully", priority="medium", queue_name="run" )
# Progressive breadcrumbs (inside loops or long phases)
for i, item in enumerate( items ):
await voice_io.notify( f"Processing {i + 1} of {len( items )}", priority="low", queue_name="run" )
finally:
voice_io.clear_job_id()
CRITICAL: Every notify() call MUST include queue_name="run" for proper queue routing.
set_job_id() / clear_job_id() enables job card activity log routing in the UI.
Breadcrumb notifications are required for loops, long phases (>10s), and dry-run mode. See "Progressive Breadcrumb Notifications" in the full workflow doc.
run() - Main entry pointget_state() - Current orchestrator stateget_progress() - Completion percentageget_result() - Final output{prefix}-{uuid4[:8]}MANDATE: Every new agentic job MUST satisfy ALL items before merge.
This checklist was created after a Session 381 audit found consistency gaps
across 6 existing job implementations (see src/rnd/v0.1.6/2026.03.27-bug-fix-expediter/).
from_config( config_mgr, debug ) classmethodlupin-app.ini under agent-specific prefixlupin-app-splainer.ini_execute method)voice_io.set_job_id( self.id_hash ) called at start of _execute()voice_io.clear_job_id() called in finally blocknotify() calls include queue_name="run" (live AND dry-run)self.answer_conversational set before job completesself.error set on failure with descriptive message_execute_dry_run() is a separate method (not flag check in _execute())job_id=self.id_hash and queue_name="run"abstract with mock cost summaryself.artifacts[ "cost_summary" ]if/elif๐ด This section was rewritten 2026-08-25. If you are reading a copy that tells you
to add a factory branch to agentic_job_factory.py, that copy is stale โ the
if/elif it points at is the defect that was removed, and adding a new one
recreates it.
Registration is ONE contract entry. Add your command to JOB_ARG_CONTRACTS
(src/cosa/agents/runtime_argument_expeditor/agent_registry.py) and the v2 registry
builds its AgentSpec for you โ the agentic set is a comprehension over that table
(_AGENTIC = tuple( _agentic_spec( command, entry ) for command, entry in JOB_ARG_CONTRACTS.items() )
in src/cosa/rest/v2/registry.py), not a second hand-maintained list. Display name,
CLI module and CLI style are all read off the same entry, so there is nothing to keep
in sync.
The contract entry in JOB_ARG_CONTRACTS โ command โ job_prefix,
cli_module, job_class_path, display_name, required_user_args,
system_provided, arg_mapping, fallback_questions.
Two ratified opt-ins, each one line. These are deliberately NOT derived
from the contract, and the registry says so in comments with reasons:
- _SPEAKABLE_AGENTIC โ "belongs in the voice router prompt".
- _USER_INITIABLE_AGENTIC โ "a person may start this by typing into the
Q&A card". Held out on purpose for commands whose only argument is a job id
produced by a job that already failed โ nobody types one of those; you press
a button on the failed job's card.
They answer different questions. Deriving either from the other works today by
coincidence and breaks on the first command that is typeable but not sayable.
One description line in _AGENTIC_DESCRIPTIONS โ the dropdown's help text.
The job-construction branch in create_agentic_job()
(src/cosa/rest/agentic_job_factory.py) โ โ ๏ธ still required today, and this
is the one place an elif is still correct. The registry carries a
job_factory field for exactly this, but it is None in phase 1 and phase 5
(dispatch by lookup) is not wired yet. Verified in the tree 2026-08-25: the
chain is live with eleven branches. Add yours, and expect to delete it when
phase 5 lands.
What you do NOT add: a routing branch. The v2 registry replaced the three v1
routing mechanisms that had drifted apart โ the LLM-routing if/elif reached from
push_job() / get_routing_command(), the MODE_TO_AGENT map, and the command
if/elif that used to decide routing. Resolution is resolve() / resolve_agentic()
and nothing else; no caller picks a factory out by name.
src/tests/unit/test_v2_registry_drift_guard.py exists to stop them coming back.
๐ด Know which elif is which. "No if/elif" is about ROUTING โ deciding
which command this is. Job construction โ turning a resolved command plus its
arguments into a Job object โ still dispatches by branch until phase 5. Reading the
rule as "no branches anywhere" leaves a new agent unbuildable; reading it as "branches
are fine" recreates the defect.
The single door is AskFlow (src/cosa/rest/v2/flow.py), reached from
POST /api/v2/ask and POST /api/v2/submit. It resolves the command against the
registry, runs the Runtime Argument Expeditor to fill arguments, asks the user when
one is missing, and hands the built job to the queue.
๐ด Do not add an if/elif anywhere for routing. That is the defect being
removed, and a new one recreates it. If your command needs behaviour the registry
cannot express, the fix is a new field on the entry โ the way crud_factory,
label, dings and user_initiable were each added โ not a branch that reads the
command name and decides.
resolve_agentic(); no caller reaches your factory
by picking it out itself.if command == ... / elif in the routing path.A command declares what it needs AND what to ask when it is missing, side by side, so the two cannot drift:
"agent router go to deep research" : {
"required_user_args" : [ "query" ],
"fallback_questions" : {
"query" : "What topic would you like me to research?",
"budget" : "Would you like to set a budget limit in dollars? Say a dollar amount, or 'no limit'.",
},
"fallback_defaults" : { "budget" : "no limit" },
}
required_user_args has a fallback_questions entry.arg_mapping maps what the router actually emits to your CLI's names.
โ ๏ธ Map only what genuinely means the same thing. Aliasing topic โ research
once delivered a spoken subject phrase where a file path was expected, and the
job failed on a file nobody had named (row 9d89afe2). Leaving it unmapped lets
the argument stay MISSING and the question fire, which is the correct outcome.๐ด The three items above register an agent; they do not make it reachable.
The router only emits commands it has been told about. An agent that satisfies
every other item in this checklist and skips these two is callable by API and
mute by voice โ the exact failure this section was added to stop
(2026-08-15; see src/rnd/v0.2.0/2026.08.15-agent-registration-single-source.md ยง3).
Listing the command in the prompt makes it reachable immediately. The retrain only makes it dependable. The two are not interchangeable and they land at different times.
Live A/B on the production adapter, same model, same training set, only the prompt
differing (src/rnd/v0.2.0/2026.08.15-router-emission-probe.md):
| arm | prompt-listed? | trained? | emitted correctly |
|---|---|---|---|
test fix expediter resume |
no | yes | 0/5 โ routed to swe team every time |
test fix expediter resume |
yes | yes | 5/5 |
bug fix expediter |
no | no | 0/5 โ routed to claude code every time |
โ ๏ธ Read the middle column before quoting these. Both arms of the A/B were trained, so this shows prompt-listing is sufficient given training. It does not isolate prompt-listing without training โ no command in the tree is prompt-listed and never trained, so that arm could not be run.
โ ๏ธ One earlier claim in this skill did NOT reproduce and has been removed. It said an unlisted command "still fires 6/10", i.e. that a missing entry causes intermittent failure. On this adapter the unlisted arms emitted the target 0/5 โ the model did not invent, it confidently substituted the nearest listed command. A missing prompt line is a clean, total, silent misroute, not a flaky one. Treat the old number as retired.
<command> line added to the router prompt template
src/conf/prompts/agent-router-template-completion.txt
โ this is what makes the command reachable at all, today, with no retrain
and no server bounce.src/conf/training/agent-router-*.json
โ โ ๏ธ scope matters: agent-router-*.json only. The same folder holds
vox-cmd-*.json, a separate browser-command namespace that shares nothing
with this registry except the key none.
โ โฑ๏ธ Consumed at TRAIN time, baked into the LoRA weights. Adding the key
does not make the command live today; it makes it dependable after the next
retrain.
โ ๐ The training artifact now carries a fingerprint of the corpus it was built
from, and a training run REFUSES if the two disagree (row 11390b57). Edit the
corpus and the next run stops and names which side is stale, instead of quietly
training on the previous corpus.Verify, don't assume: after adding both, confirm the command actually routes before calling the work done.
๐ด These are registration lists too, defended by the single-source drift guard
(src/tests/unit/test_v2_registry_drift_guard.py). Registering and voice-reaching an
agent (the sections above) is still not the whole story: an agentic command that skips
these two is card-unreachable and/or turns the auto-proxy suite red.
PRODUCT_NAMES in todo_fifo_queue.py. This is what lets _confirm_agentic_routing()
offer the command as a "Switch to this instead" alternative on the voice
confirmation card. Omit it and the command is invisible on the card even when the
router emits it. (A command deliberately NEVER card-reachable โ e.g. a
system-triggered job needing a pasted job hash โ instead carries a drift-guard
exemption that waives the card surface, with a stated reason.)fallback_questions arg
names to the all_agents profile in notification_proxy/config.py. The proxy
coverage test asserts that union profile can auto-answer every agent's interview;
a new agent whose args are absent turns that test red.Use src/cosa/agents/deep_research/job.py as the gold standard โ it satisfies
every item in this checklist.
Full Workflow Document: src/workflow/agentic-voice-workflow.md
Contains:
All agentic jobs automatically support timed execution and exclusive mode. These are
runtime infrastructure concerns, not agent-specific features. No per-agent registration,
factory changes, or _execute() modifications needed.
UI form path: Every job submission card includes a "Schedule for later" checkbox +
datetime picker and an "Exclusive mode" checkbox. The JS _getSchedulingParams() helper
adds scheduled_at (ISO string) and monopolize (bool) to the POST body when set.
Voice path: The Runtime Argument Expeditor's confirmation summary automatically includes:
---
**Scheduling**
- **run_at**: immediately
- **exclusive_mode**: no
Users can modify via the existing [comment: ...] pattern:
scheduled_atmonopolize = TrueRuntime arg extraction: AskFlow._split_queue_directives() (src/cosa/rest/v2/flow.py)
pops scheduled_at and monopolize out of the argument dict before the brain builds
the job, then passes them to the factory as queue directives. (parent_id_hash rides
the same path from the request body.)
๐ด THEY ARE NOT AGENT ARGUMENTS, AND THIS IS EASY TO GET WRONG โ because they
arrive in the same dictionary as real arguments. The expeditor offers scheduled_at
and monopolize for every agentic command, so "run the deep research at ten
tomorrow" comes back as ordinary keys alongside query. They say WHEN and HOW the
queue should run the work, never what the agent should do with it.
The failure mode if you treat them as arguments, or forget to split them out:
create_agentic_job reads its arguments by name and does not name these, so a
left-in scheduled_at is dropped in silence and the job runs immediately โ the
caller believes it is scheduled and it is not. Equally, they cannot be passed inside
args: args is validated against the command's argument contract, and no command's
contract names them.
scheduled_at / monopolize to your JOB_ARG_CONTRACTS entry._execute()."immediately" / "now" / "none" normalise to no schedule โ a user who says
"immediately" must keep meaning it.A newly registered command is not flipped live on its first day. It goes through
shadow first: the path is exercised and logged, and what it would have done is
recorded rather than executed. The INI keys carry it per-agent, e.g.
swe team trust mode = shadow, bug fix expediter trust mode = shadow in
src/conf/lupin-app.ini, with the ladder documented in lupin-app-splainer.ini:
| mode | what happens |
|---|---|
disabled |
no proxy at all |
shadow |
observe and log only โ compute the decision, do not execute it |
suggest |
surface the suggestion to the user, who still decides |
active |
act automatically at the trust level the agent has earned |
Default is shadow for a reason: observability first. You get a real trace of
what the command would have done, on real traffic, before it can do it.
shadow in lupin-app.ini.suggest/active only after the shadow trace shows the decisions were
the ones you wanted โ that trace is the evidence, and without it the flip is a
guess.scheduled_at = None โ immediate execution (default)scheduled_at = "2026-03-31T02:00:00" โ consumer sleeps until that timemonopolize = True โ no-op in serial mode; when Hybrid Fast Lane is added, blocks
all concurrent jobs until this one completesscheduled_at / monopolize to your agent's contract entry (they are in no
command's argument contract โ see the runtime-arg note above)_execute() methodqueue_name="run" from notify() calls - breaks queue routingset_job_id() / clear_job_id() - breaks job card activity logfrom_config() classmethodvoice_io without strong justificationEvery new agent MUST have an automated live pipeline test before merge. Do not rely on manual curl or UI-click testing for pipeline validation. The automated infrastructure exists โ use it.
Prefer automated smoke test scripts over manual curl submissions.
| Approach | Effort | Repeatability | Example |
|---|---|---|---|
Manual curl POST to /api/push |
High (copy-paste, edit JSON, poll manually) | Low | Ad-hoc debugging only |
| Automated smoke test script | Low (single command) | High | src/tests/smoke/test_calculator_live_pipeline.py |
Pattern: test_calculator_live_pipeline.py demonstrates the preferred approach:
/auth/login, get JWT/api/mode/current/api/push, extract job_id from response/api/get-queue/done by job_id until completionWhen building new agents, create an automated smoke test following this pattern rather than relying on manual curl commands.
LivePipelineTestBase)Copy and adapt this template for agents that do not ask interactive questions:
#!/usr/bin/env python3
"""
Smoke test for {AgentName} agent via live pipeline.
Usage:
python src/tests/smoke/test_{agent_name}_live_pipeline.py
python src/tests/smoke/test_{agent_name}_live_pipeline.py -q 0,2
Requires:
- Server running on localhost:7999
- LUPIN_TEST_INTERACTIVE_MOCK_JOBS_EMAIL / LUPIN_TEST_INTERACTIVE_MOCK_JOBS_PASSWORD
"""
import os
import sys
lupin_root = os.environ.get( "LUPIN_ROOT" )
if lupin_root:
sys.path.insert( 0, os.path.join( lupin_root, "src" ) )
from tests.smoke.utilities.live_pipeline_base import LivePipelineTestBase
{AGENT_NAME_UPPER}_QUERIES = [
{
"id" : "SCENARIO_1",
"query" : "Your test query here",
"expected_keywords" : [ "expected", "words" ],
},
# Add more scenarios...
]
class {AgentName}PipelineTest( LivePipelineTestBase ):
TEST_NAME = "{Agent Name} Live Pipeline"
SCENARIOS = {AGENT_NAME_UPPER}_QUERIES
DEFAULT_TIMEOUT = 120
def build_argparser( self ):
parser = super().build_argparser()
parser.add_argument( "--queries", "-q", type=str, default=None,
help="Comma-separated query indices (e.g., '0,1,3'). Default: all." )
return parser
def get_scenario_indices( self, args ):
if hasattr( args, "queries" ) and args.queries:
return [ int( x.strip() ) for x in args.queries.split( "," )
if int( x.strip() ) < len( self.SCENARIOS ) ]
return list( range( len( self.SCENARIOS ) ) )
def get_mode_for_scenario( self, scenario ):
return "{agent_name}" # Or None for auto-route testing
def quick_smoke_test():
import argparse
test = {AgentName}PipelineTest()
args = argparse.Namespace( queries=None, debug=False, verbose=False )
return test.run_scenarios( args )
def test_{agent_name}_live_pipeline():
assert quick_smoke_test()
if __name__ == "__main__":
test = {AgentName}PipelineTest()
success = test.run( sys.argv[ 1: ] )
sys.exit( 0 if success else 1 )
InteractiveSmokeTest)For agents that ask interactive questions via the Runtime Argument Expediter, use InteractiveSmokeTest instead:
from tests.smoke.utilities.interactive_smoke_test import InteractiveSmokeTest
class {AgentName}InteractiveTest( InteractiveSmokeTest ):
TEST_NAME = "{Agent Name} Interactive"
SCENARIOS = {AGENT_NAME_UPPER}_SCENARIOS
PROXY_PROFILE = "{agent_name}"
DEFAULT_TIMEOUT = 180
Run with: python src/tests/smoke/test_{agent_name}_live_pipeline.py --auto-proxy --no-confirm
| File | Purpose |
|---|---|
src/tests/smoke/utilities/live_pipeline_base.py |
Base class: auth, submit-and-poll, validation, reporting |
src/tests/smoke/utilities/interactive_smoke_test.py |
Adds proxy auto-launch for interactive agents |
src/tests/smoke/test_calculator_live_pipeline.py |
Reference: non-interactive (6 scenarios) |
src/tests/smoke/test_proxy_integration.py |
Reference: interactive (12 scenarios, 3 agent groups) |
src/docs/automated-interactive-testing.md |
Comprehensive proxy testing guide |
For agents with interactive questions: Also create a Notification Proxy Q&A script so expediter questions are auto-answered during automated testing. See "Notification Proxy" section below.
Planned (v0.1.6): Playwright-based UI E2E tests will add browser-level validation (submit via UI, verify job cards, check notification rendering). When implemented, update this SKILL.md with the Playwright test template and add a Phase 5c section.
When agents ask interactive questions (via Runtime Argument Expediter), smoke tests stall without human input. The Notification Proxy solves this by loading a JSON Q&A script at startup and using Phi-4 local LLM to semantically match incoming questions to scripted answers.
fallback_questions in agent_registry.pycp _template.json your-agent.json in src/conf/notification-proxy-scripts/question_pattern, answer, arg_name, response_types__main__.py choices and config.py TEST_PROFILESEach entry in the entries array follows this format:
{
"question_pattern" : "What topic would you like me to research?",
"answer" : "quantum computing breakthroughs 2026",
"arg_name" : "query",
"response_types" : [ "open_ended", "open_ended_batch" ]
}
For all-agents.json, scope entries to specific agents with the agents tag:
{
"question_pattern" : "What topic?",
"answer" : "quantum computing",
"agents" : [ "deep_research", "research_to_podcast" ]
}
Entries without an agents tag are universal and apply to any agent.
# Terminal 1: Start proxy with your agent's profile
python -m cosa.agents.notification_proxy --profile your_agent --debug
# Terminal 2: Run the smoke test
python src/tests/smoke/test_your_agent_live_pipeline.py
| File | Purpose |
|---|---|
src/conf/notification-proxy-scripts/README.md |
Full guide for creating Q&A scripts |
src/conf/notification-proxy-scripts/_template.json |
Copy-and-modify starter template |
src/cosa/agents/notification_proxy/config.py |
Profile registration (backward compat) |
src/cosa/agents/runtime_argument_expeditor/agent_registry.py |
Defines questions per agent |
Reference: src/conf/notification-proxy-scripts/README.md