Skip to main content

Playbook authoring guide

These rules came from the 9-round travel agent flagship round (sync/issues/2026-05-09-travel-agent-widget-flagship.md). Each rule is general enough for NoETL playbook and agent authors to use without repeating the same AMBER to GREEN cycle. The examples are deliberately small: copy the pattern, then adapt the surrounding workflow to your agent.

Keychain rules

Use bare workload references in keychain templates

NoETL keychain blocks evaluate Jinja with workload values merged into the top-level scope. Reference workload fields by their bare name, such as {{ gcp_auth }} or {{ openai_secret_path }}. Using {{ workload.X }} can render as an empty string and leave downstream HTTP calls with an empty bearer token.

Good:

keychain:
- name: openai_token
auth: "{{ gcp_auth }}"
map:
api_key: "{{ openai_secret_path }}"

Bad:

keychain:
- name: openai_token
auth: "{{ workload.gcp_auth }}"
map:
api_key: "{{ workload.openai_secret_path }}" # binds empty

Surfaced in round 2. Evidence: bridge/outbox/20260509-054530-travel-keychain-amber-to-green.result.json.

Keep OAuth2 keychain endpoint URLs as plain strings

Do not put Jinja conditionals in an OAuth2 keychain endpoint. The endpoint field is part of credential/token acquisition, and the travel arc showed that conditional endpoint templates did not render in that context. Register separate playbooks or construct environment-specific calls in a normal step when the endpoint really must vary.

Good:

keychain:
- name: amadeus_token
endpoint: https://test.api.amadeus.com/v1/security/oauth2/token

Bad:

keychain:
- name: amadeus_token
endpoint: "{{ 'https://test.api.amadeus.com' if env == 'test' else 'https://api.amadeus.com' }}/v1/security/oauth2/token"

Surfaced in round 2. Evidence: bridge/outbox/20260509-054530-travel-keychain-amber-to-green.result.json.

Step semantics

Use semantic fields instead of status: failed for handled failures

result.status is not just descriptive metadata. If a step returns status: failed, NoETL treats that step as failed at the workflow level. For handled upstream failures, keep the execution successful and put the semantic state in a different field such as outcome, kind, or category.

Good:

result = {
"outcome": "amadeus_failure",
"upstream_status_code": 500,
"render": render_widget,
}

Bad:

result = {
"status": "failed",
"upstream_status_code": 500,
"render": render_widget,
}

Surfaced in round 4. Evidence: bridge/outbox/20260509-061206-travel-failure-status-amber-to-green.result.json.

Make the render-emitting step the workflow tail

NoETL sets execution.result from the last step on the executed branch. If a render-producing step is followed by postgres, http, or noop, that later step can clobber the render payload. Put audit inserts and gateway callbacks inside the render step as side effects when the GUI must read execution.result.render.

Good:

- step: render_flights
tool:
kind: python
code: |
# Optional side effects happen here: psycopg audit, urllib callback.
result = {
"text": "Flight search complete",
"render": render_widget,
}
# No next.arcs. This is the tail for the branch.

Bad:

- step: render_flights
next:
arcs:
- step: persist_and_callback

- step: persist_and_callback
tool:
kind: postgres
next:
arcs:
- step: end

- step: end

Surfaced in round 6. Evidence: bridge/outbox/20260509-065650-travel-render-tail-amber-to-green.result.json.

External HTTP calls

Wrap third-party HTTP calls when non-2xx responses are business data

The NoETL HTTP tool currently aborts the step on non-2xx responses after retries are exhausted. That is right for many infrastructure calls, but not for agent-facing APIs where a 4xx or 5xx should become a friendly widget. Until noetl#88 preserves HTTP error response bodies for conditional routing, use a kind: python wrapper with urllib.request and return a uniform envelope.

Good:

- step: amadeus_call_flights
tool:
kind: python
code: |
import json
import urllib.error
import urllib.request

try:
with urllib.request.urlopen(request, timeout=30) as response:
result = {
"ok": True,
"status_code": response.status,
"body": json.loads(response.read().decode("utf-8")),
}
except urllib.error.HTTPError as exc:
result = {
"ok": False,
"status_code": exc.code,
"body": json.loads(exc.read().decode("utf-8") or "{}"),
}

Bad:

- step: amadeus_search_flights
tool:
kind: http
method: POST
url: https://test.api.amadeus.com/v2/shopping/flight-offers
retry:
max_attempts: 3

Surfaced in round 3. Evidence: bridge/outbox/20260509-060007-travel-amadeus-urllib-amber-to-green.result.json.

YAML and SQL quoting

Keep integer payload fields as integers

YAML-quoted Jinja renders strings, even when the expression includes an | int filter. Some APIs care about JSON type fidelity, so do not template numeric fields inside quotes. Hardcode known integer values or construct the request body in Python and pass the typed object through.

Good:

searchCriteria:
maxFlightOffers: 10

Bad:

searchCriteria:
maxFlightOffers: "{{ workload.maxFlightOffers | default(10) | int }}"

Surfaced in round 3. Evidence: bridge/outbox/20260509-054530-travel-keychain-amber-to-green.result.json.

Pre-serialize JSON before inserting it with SQL templates

Inline tojson | replace expressions are fragile inside YAML literal SQL blocks. Serialize the dict into a string in a Python step, then reference that single string field from SQL and escape apostrophes there. This mirrors the proven Amadeus e2e pattern.

Good:

result["json_str"] = json.dumps(result, separators=(",", ":"))
'{{ parse_classification.json_str | replace("'", "''") }}'::jsonb

Bad:

'{{ parse_classification | tojson | replace("'", "''") }}'::jsonb

Surfaced in round 1. Evidence: bridge/outbox/20260509-051749-travel-agent-amber-to-green.result.json.

GUI integration

Grep the GUI for duplicate playbook path constants

When changing the canonical catalog path for a GUI-backed workflow, search the whole src/ tree. At least one component and one service layer may hold separate constants. Updating only the visible component can leave the execution service pointing at the old playbook.

Good:

// src/components/GatewayAssistant.tsx
const TRAVEL_PLAYBOOK_PATH = "automation/agents/travel/runtime";

// src/services/gatewayAuth.ts
const PLAYBOOK_NAME = "automation/agents/travel/runtime";

Bad:

// Component updated, service still points to the old fixture.
const TRAVEL_PLAYBOOK_PATH = "automation/agents/travel/runtime";
const PLAYBOOK_NAME = "api_integration/amadeus_ai_api";

Surfaced in round 5. Evidence: bridge/outbox/20260509-063112-travel-bubble-render-and-canvas-amber-to-green.result.json.

Wrap Enter-handling inputs in an explicit form submit guard

Input.Search and other Enter-handling inputs can still trigger the browser's native submit behavior in routed pages. If that submit goes to /, the app router can redirect the user to the default page and unmount the working surface. Wrap those inputs in a form that calls preventDefault() and dispatches the intended handler.

Good:

<form
onSubmit={(event) => {
event.preventDefault();
if (query.trim() && !submitting) onSubmit(query);
}}
>
<Input.Search
value={query}
onChange={(event) => setQuery(event.target.value)}
onSearch={onSubmit}
/>
</form>

Bad:

<Input.Search
value={query}
onChange={(event) => setQuery(event.target.value)}
onSearch={onSubmit}
/>

Surfaced in round 7. Evidence: bridge/outbox/20260509-065650-travel-render-tail-amber-to-green.result.json.

Stop widget button clicks at the widget boundary

Widget buttons are often rendered inside forms, cards, prompt entries, or routed pages. The widget callback should fire once, and parent surfaces should not also see the click as form or navigation activity. Consume the DOM event before emitting the widget event.

Good:

onClick={(clickEvent) => {
clickEvent.preventDefault();
clickEvent.stopPropagation();
onWidgetEvent({
event: "onPressEvent",
key: event.key,
value: event.value,
});
}}

Bad:

onClick={() =>
onWidgetEvent({
event: "onPressEvent",
key: event.key,
value: event.value,
})
}

Surfaced in round 8. Evidence: bridge/outbox/20260509-155138-canvas-widget-rerun-green.result.json.

Preserve sourcePrompt for widget-driven reruns

A widget command like rerun <execution_id> is meaningful in the terminal prompt because the prompt has a command parser. A canvas or assistant surface needs the original semantic input as well. Store the source prompt alongside the assistant message, then map widget reruns back to that prompt.

Good:

type ChatMessage = {
role: "user" | "assistant";
text: string;
sourcePrompt?: string;
};

if (command.startsWith("rerun ")) {
onSubmit(message.sourcePrompt || message.text);
}

Bad:

// The canvas does not know what query this execution id represents.
runCommand(`rerun ${executionId}`);

Surfaced in round 8. Evidence: bridge/outbox/20260509-155138-canvas-widget-rerun-green.result.json.

See also