Ask the user: interactive turns
A turn step can stop and ask the user a question.
A turn step can stop and ask the user a question. Kusaw saves the question, waits for the answer, and then runs the step again with it. Each run of the program handles one answer, so the step needs no open terminal and no long-running process. Kusaw keeps a step's questions and answers together as one conversation.
This page walks through examples/custom-steps/ask-step, a Python 3 step that uses only the standard library. It asks where to deploy (a choice, with Staging recommended) and for a one-line release note, then writes answers.md. Read Write your first step first; this page covers only what is different for a turn step.
Turn steps run only in the intake list for now, not in pipeline, triage or finally.
1. Say turns: true in the handshake
# Handshake: one JSON line, then exit 0. kusaw validate calls this.
def handshake():
emit({"type": "handshake", "protocol_versions": [2], "name": "ask-step",
"version": "1.0.0", "interactive": False, "turns": True,
"description": "Asks where to deploy and for a release note, one question per turn"})turns: true tells Kusaw that the step may return the status await_input. A step without it can't ask questions.
2. Ask a question
On the first run, request.turn.reply is null. The step returns the status await_input with a turn object: the prompt (Markdown), optional choices, how the user answers (input), a placeholder for the answer box, and any state it wants back on the next run.
def ask(prompt, state, choices=None, placeholder=None):
turn = {"prompt": prompt, "state": state}
if choices:
turn["choices"] = choices
turn["input"] = "choice_or_text"
if placeholder:
turn["placeholder"] = placeholder
emit({"type": "result", "protocol_version": 2, "status": "await_input",
"message": "Waiting for your answer.", "turn": turn}) if reply is None:
choices = [{"id": i, "label": label} for i, label in ENVS]
choices[0]["recommended"] = True
ask("Where should this change be deployed?", {"asked": "environment"}, choices)
return 0Limits: a prompt is 1 to 32,768 bytes. A question has at most 8 choices, at most one of them recommended, and each label is 1 to 80 characters. state is at most 65,536 bytes as JSON, and resume_id at most 256 bytes. A question that breaks a limit fails the step with protocol_error.
3. Read the answer
On the next run, request.turn has seq (the number of the question being answered, starting at 1), reply (text, and choice_id when the user picked a choice), and the state and resume_id the step returned last time, unchanged. history_path points to turns.jsonl, with one line per earlier turn. KUSAW_TURN_SEQ and KUSAW_CONVERSATION_ID hold the same seq and the conversation id.
if state.get("asked") == "environment":
env = answer(reply) or "staging"
emit({"type": "turn_progress", "text": "Environment: {0}".format(env)})
ask("Anything to add to the release note? Leave it empty to skip.",
{"asked": "note", "environment": env}, placeholder="One line is enough")
return 0While it works, the step can print turn_progress events, such as {"type": "turn_progress", "text": "Environment: staging"}, of up to 8,192 characters each. They are shown live and are not stored as turns.
4. Finish with a result
When it has what it needs, the step ends with a normal result (ok, fail, retry or reject), like any other step.
env = state.get("environment", "staging")
note = answer(reply)
artifacts_dir = req.get("artifacts_dir") or "."
os.makedirs(artifacts_dir, exist_ok=True)
path = os.path.join(artifacts_dir, "answers.md")
with open(path, "w", encoding="utf-8") as fh:
fh.write("# Answers\n\n- Environment: {0}\n- Release note: {1}\n".format(env, note or "(none)"))
art = {"path": path, "kind": "markdown", "title": "Answers"}
result("ok", "answered", "Got both answers.", {"environment": env, "note": note}, [art])5. Interrupted answers, cancel and expiry
- If Kusaw stops or the computer sleeps while the step handles an answer, the answer is marked interrupted. When the user sends it again, the step runs with the same
turn.seq,reply,stateandresume_id, so make a repeated seq safe to handle. - A question waits for an answer for
turns.wait_timeout(default 72h, from 10m to 720h). After that the conversation expires and the step is not run again. - If the user cancels, the conversation ends and the step is not run again.
- A conversation stops at 200 questions, and a factory can have up to 20 open conversations.
6. Add it to kusaw.yaml
ask-step: { command: ["python3", "./step.py"], timeout: 2m }intake:
- ask-step
- grill
- write-spec
- spec-check7. Try it by hand
KUSAW_HANDSHAKE=1 python3 step.py
python3 step.py < request.sample.jsonThe first command prints the handshake. The second asks the first question. To answer it, set turn.reply to {"text": "", "choice_id": "staging"} and turn.state to the state the step returned, then run it again.
In a factory, the user answers with kusaw conversation reply or kusaw conversation attach (see kusaw conversation --help).
To keep one Claude Code session across questions, see Use an agent inside your step.