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.

Workload defaults

Match environment-specific workload defaults to where the playbook runs

Workload fields can chain through Jinja, such as vertex_project: "{{ workload.gcp_project }}". A default-of-a-default is invisible to every caller that does not override it, so concrete project, cluster, and region defaults must match the environment where the playbook is registered and run, not the developer's local sandbox.

Good:

workload:
# GKE project - used by every downstream Jinja chain.
gcp_project: "noetl-demo-19700101"
vertex_project: "{{ workload.gcp_project }}"
vertex_region: "us-central1"

Bad:

workload:
# Local kind sandbox name - silently misroutes every GKE caller.
gcp_project: "noetl-cluster"
vertex_project: "{{ workload.gcp_project }}"
vertex_region: "us-central1"

Surfaced in Phase 3 vertex-ai re-smoke. Evidence: bridge/outbox/20260510-040000-travel-vertex-ai-resmoke.result.json.

When a playbook truly serves multiple environments, prefer making the field required with no default and failing fast at registration time, or set the default to the production environment and require sandbox/local callers to override it. The inverse, defaulting to local, silently breaks production. This pairs with the bare-keychain-references rule above: both expose how Jinja template chains in workload binding can hide a misconfiguration that only manifests at request time.

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.

Python step authoring

Republish helpers through globals before calling them from other helpers

NoETL executes kind: python step code with separate globals and locals dictionaries. Helper functions defined at the top of the code body are visible to top-level statements, but sibling helper functions cannot reliably call each other by name unless those helpers are republished through globals.

Define all helpers first, then call globals().update({...}) before any helper invokes another helper. This is the same pattern used inside the existing automation/agents/mcp/vertex-ai.yaml playbook and was rediscovered across the travel agent provider rounds.

Good:

def _helper_one():
return _helper_two() + 1

def _helper_two():
return 42

# Republish helpers so each function can resolve sibling helpers
# through globals at call time.
globals().update({
"_helper_one": _helper_one,
"_helper_two": _helper_two,
})

result = {"value": _helper_one()}

Bad:

def _helper_one():
# NameError at runtime: _helper_two is not visible inside
# _helper_one because NoETL evaluates this snippet with separate
# globals and locals.
return _helper_two() + 1

def _helper_two():
return 42

result = {"value": _helper_one()}

Surfaced in the Phase 3 vertex-ai round and again in the hotels/activities round. Evidence: bridge/outbox/20260510-184500-travel-hotels-activities.result.json.

Why this happens: NoETL passes the user's code string to a runner that calls exec(code, globals_dict, locals_dict) with two separate maps. Top-level statements write to locals, which is the step's input scope merged with output declarations, but function definitions resolve free variables against globals at call time rather than against the locals map where sibling helpers were defined. Republishing through globals().update(...) makes helpers reachable by name from inside other helpers. import statements inside a step body work without this pattern because Python automatically writes imports into globals; user-defined functions do not.

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.

Deployment parity

Every kind deploy needs a chained GKE-parity round

Local kind is a fast acceptance surface, not proof that the production surface is current. Any round that registers playbooks, deploys GUI assets, or changes infrastructure on kind should either include an explicit GKE phase in the same bridge task or queue a chained parity round immediately afterward. The parity phase should check catalog versions, image tags, external surfaces, and the smallest smoke that proves the same user workflow works in GKE.

Do not close a production-facing round on local GREEN alone unless GKE is intentionally out of scope and the result file says so. Otherwise, drift accumulates quietly: the local cluster has the new catalog or asset, while the gateway, GKE API, or storage tier still serves the old state until a user rediscovers the gap.

Good:

# Bridge task explicitly chains GKE parity after kind acceptance.
phases:
5_smoke_kind:
objective: Verify the change on kind
6_deploy_to_gke:
objective: Apply the same change to GKE and smoke the user path
7_close_out:
objective: Document both kind and GKE evidence
# Or queue a follow-up when GKE ownership/credentials are separate.
phases:
5_smoke_kind:
objective: Verify the change on kind
6_queue_gke_parity:
objective: Write a bridge task for GKE deploy + smoke

Bad:

# Round assumes local GREEN is the production success criterion.
phases:
5_smoke_kind:
objective: Verify on kind. Done.

# Result: GKE drift accumulates silently until a user reports it.

Rediscovered during the GKE parity, gateway terminal, and storage rollout rounds. Evidence: bridge/outbox/20260511-110000-gke-parity-sync.result.json, bridge/outbox/20260511-130000-gateway-terminal-surface-and-gui-bump.result.json, and bridge/outbox/20260511-150000-eliminate-minio-add-seaweedfs-rustfs-chooser.result.json.

See also