agentsclimarketplace

Aevatar workflow authoring

Skill ChronoAIProject/NyxID/skills/aevatar-workflow-authoring

Author, preview, validate, and persist an executable aevatar workflow from a natural-language request. Use it when the user wants to create, build, set up, automate, or feature-probe a multi-step task as a runnable Aevatar workflow. It covers exact NyxID operation and authored-request admission, bounded YAML, file inputs, terminal run verification, feature-equivalent evidence boundaries, and reusable publication. Not for blindly rerunning an existing failed workflow.From its SKILL.md

Install
npx -y skills add ChronoAIProject/NyxID --skill aevatar-workflow-authoring

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

SKILL.md

37.7 KB, as published. Nobody here has run it

Authoring an executable aevatar workflow

You turn a user's natural-language request into a valid, test-run, reusable aevatar workflow. A workflow is a YAML document of roles + steps that the engine executes; once validated you persist it as a skill so the user can re-run it and watch it in the observatory.

The core DSL, engine rules, and tool protocol are here. Load the linked REST or worked-example reference only when that surface is relevant. Follow the protocol in order.

Two execution surfaces — know which one you are before step 3. Steps 3 / 5 / 6 below call the server-side agent tools nyxid_services, aevatar_start_workflow, and ornn_publish_skill. Those exist only when you are the model running inside an aevatar session with the nyxid MCP connected. If instead you are an external client holding only a NyxID bearer token — driving the aevatar backend through the NyxID broker (nyxid proxy request aevatar), the same identity the sibling skills (aevatar-team-builder, aevatar-service-publisher, aevatar-scheduler) assume — those three tools are not callable. Read references/client-rest.md for the client dry-run, publication, and invocation path; the core DSL and engine rules below apply to both surfaces.


Protocol (follow in order)

  1. Confirm the intent is authoring. The user wants a new runnable workflow. If they want to run something that already exists, stop and search for it instead.
  2. Clarify just enough. Pin down: the trigger/input, how that ingress authenticates its source, the ordered steps, the desired output, and which external services (if any) are involved. Ask only what you cannot reasonably infer; do not over-interrogate.
  3. Select the exact external capability (only if external calls are needed). Prefer a published operation: call list_external_workflow_capabilities, copy its exact user_service_id + endpoint_id selector, then inspect readiness for interactive or durable. The YAML/proto selector has no operation_id field. Use typed capability.nyxid_request only when no published operation represents a required HTTP request; it needs an exact UserService selected from authoritative /api/v1/keys, a typed method/path/body contract, and bind-time authenticated confirmation/grant. One step carries exactly one of nyxid_operation or nyxid_request, never both. /api/v1/user-services is only a routing projection and is never execution authority.
  4. Author the YAML. Apply the DSL below and obey every rule in Engine rules (must obey). Prefer the reliable-core primitives; use advanced primitives only when the task truly needs them.
  5. Preview before execution. Use the explicit-request preview or draft-run readiness surface. Confirm unique call sites, read/write classification, approval requirements, exact workflow/revision identity, and every blocker. Preview/readiness proves admission readiness only; it does not prove runtime credential propagation or downstream authorization.
  6. Run once when authorized, then observe the same run. Never use another mutation as a status check. For a real acceptance run, require terminal completion, lastSuccess=true, non-empty required step outputs, and non-empty final output. On failure, read run detail and audit, find the first failed step, and diagnose before any rerun. Do not blindly retry a mutation or a failed workflow. If the original definition is blocked by a stale or inaccessible external resource, a separately authorized feature-equivalent probe may substitute an accessible sanitized resource while preserving the relevant step, selector, transform, file, and fan-out shapes. Its success proves only those platform features; the original workflow remains blocked or unproven. Never add sends, approvals, external mutations, or schedules merely to broaden a probe.
  7. Persist as a reusable workflow. After structural validation and any required execution proof, call ornn_publish_skill with the final workflow in workflow_yamls (see Persisting). This creates a private skill in the user's account containing the workflow.
  8. Report evidence honestly. Separate preview-ready, accepted, completed, business-verified, blocked by policy, and failed. Do not call accepted or an opened SSE stream a successful run.
  9. Iterate on request. To change an existing workflow: load it with use_skill, edit the YAML, repeat preview and validation, and publish a new version.

Keep tool rounds bounded, but do not trade away terminal evidence. Start one authorized run, record its identity privately, and observe that same run through the run/read-model surfaces.


Engine rules (must obey)

These are the failure modes that break generated workflows. Check every one before validating.

  • Single terminal step. A run ends at the step that has no next and is last in document order. Make the final step the last line of the document.
  • Bounded YAML. Each YAML document is limited to 1 MiB UTF-8, 10,000 parsed nodes, and collection nesting depth 64. Aliases do not bypass these limits. Treat a resource-limit rejection as a document defect; reduce the document instead of retrying it.
  • Fall-through is by document order, not id order. A step with no next falls through to the next step written in the file. So every branch must reach the terminal step via an explicit next, and nothing should sit after the terminal step. Getting this wrong silently overwrites your output.
  • No ambient workflow clock. The engine does not invent "today" for a normal invocation. The caller must inject dates through run input. A Team automation can render {{@schedule.run_date}}, run_year, run_month, and days_until_month_end from its authoritative logical fire plus configured timezone; a scope webhook binding can render {{@run_date}} from received time plus its explicit timeZoneId. These are ingress contracts, not workflow expression variables. Never read host wall-clock time or ask an LLM to guess it.
  • Input authority follows the ingress contract. Classify the source before adding a compensating provider read. A shared or unverified endpoint authenticates neither the sender nor its claims: accept only a locator such as record_id, then read authoritative fields through an admitted provider capability. A dedicated automation credential or HMAC-verified Host/Adapter path may treat pushed fields as workflow input only when that principal is exclusive to the source and the exact body is authenticated. Caller authentication alone is insufficient when people or unrelated systems share the credential. Preserve source/delivery provenance, make idempotency and deduplication explicit, and read the provider only for fields the trusted payload omitted, freshness after the trigger, or an independent business rule. For external-trigger wiring, hand off to aevatar-service-publisher; do not invent a second workflow lifecycle.
  • Role is not model. target_role selects the actor, not a model — never put a model name in target_role. A role may carry provider/model, but set them only when the user explicitly wants a specific model; otherwise omit and let the session default apply.
  • parameters values are strings. Bare words are read as strings (op: trim); quote anything numeric or boolean so it stays a string (n: "50", max_iterations: "5").
  • Determinism for money/counts/dedup. Use transform (sum, group_by, round, …) for any arithmetic, totals, or deduplication. Never let an llm_call compute amounts or counts.
  • Transform operations are an allowlist. Supported operations are identity, count, count_words, take, take_last, join, split, json_extract, json_parse, rss_extract_items, distinct, uppercase, lowercase, trim, reverse_lines, sum, subtract, multiply, divide, round, min, max, group_by, and template. There is no replace or regex_replace. The current runtime treats an unknown operation as identity and can report the step successful, so reject any unlisted op during authoring and assert the transformed output. If deterministic string replacement is essential, use a bounded code_execute step only when its admission policy permits it; never substitute an LLM.
  • Side effects are at-least-once. tool_call / connector_call may run more than once on retry. Keep them idempotent where it matters.
  • External calls go through tools, not raw hosts. Use nyxid_proxy (or a typed tool) — never embed a vendor base URL as a direct target. See Accessing external services.
  • Files are typed inputs. input_file_refs is not $input text and not an interpolation variable. Use foreach with items_source: input_file_refs to process multiple files; file tools are still invoked through type: tool_call.
  • Ambient files are fallback inputs. When the caller supplies no explicit fileRef, the workflow start appends deduplicated ambient file refs from the current tool context. Any explicit fileRef suppresses ambient refs, so never merge the two sets client-side. An empty inputs object is valid when ambient refs exist. Role LLM tool loops receive the same typed refs; do not flatten them into prompt text or reconstruct them from IDs.
  • Template programs are static and bounded. transform op: template accepts bounded JSON input plus a typed template program. The program is not supplied by upstream workflow ${...} data. It exposes only append, date, get, json, keys, number, and round; default builtins, CLR access, template loaders, files, and network are unavailable. Limits are 256 KiB for the template, 4 MiB each for input and output, 10,000 loop iterations or mutable-array items, and depth 64. Missing variables, mutation of input data, invalid JSON/template syntax, evaluation errors, and limit violations fail closed.

DSL quick reference

Top-level shape

name: my_workflow            # identifier
description: what it does     # optional
roles: [ ... ]               # actors
steps: [ ... ]               # ordered execution

Roles

roles:
  - id: analyst                       # referenced by step.target_role
    name: Analyst                     # optional display name
    system_prompt: "You are a strict analyst."
    # optional, usually omit and inherit session defaults:
    # provider: openai
    # temperature: "0.2"
    # allowed_tools: [web_search]     # ceiling of agent tools this role can see; [] = none
    # connectors: [my_api]            # whitelist for connector_call

agent_kind defaults to workflow.role-agent. Omit model (see Engine rules). allowed_tools: [] means the role exposes no agent tools.

Step shape

steps:
  - id: step_a                 # unique within the workflow
    type: llm_call             # primitive type (see table)
    target_role: analyst       # which role runs it (alias: role); some types need none
    parameters:                # all values are strings
      prompt_prefix: "Analyze:"
    next: step_b               # explicit successor; omit only on the final step
    branches:                  # for conditional/switch/vote: branch key -> step id
      true: step_b
      false: step_c
    # compensation: undo_step  # only tool_call/connector_call/secure_connector_call
    # allowed_tools: [web_search]  # only llm_call: narrow tool scope (intersection with role)

Reliable-core primitives (prefer these)

llm_call — run the target role's LLM.

- id: analyze
  type: llm_call
  target_role: analyst
  parameters: { prompt_prefix: "Summarize the input:" }

tool_call — call a registered tool (incl. nyxid_proxy, code_execute, document_extract, workflow_file_submit). A JSON-object result is mirrored to steps.<id>.json.<field> for later branching.

- id: fetch
  type: tool_call
  capability:
    nyxid_operation:
      user_service_id: <copied-user-service-id>
      endpoint_id: <listed-endpoint-id>
  parameters:
    tool: nyxid_proxy
    arguments: '{"query":{}}'

code_execute — run deterministic Python/JavaScript/TypeScript/Bash in the sandbox. Do not call external services or LLMs from code_execute; use nyxid_proxy for external services and llm_call for LLM work.

- id: build_payload
  type: tool_call
  parameters:
    tool: code_execute
    arguments: '{"language":"python","code":"import json\nprint(json.dumps({\"ok\": True}))"}'

document_extract — extract text from one current workflow file ref.

- id: extract_file
  type: tool_call
  parameters:
    tool: document_extract
    arguments: "{}"

workflow_file_submit — upload an existing workflow file ref to a NyxID service.

- id: submit_file
  type: tool_call
  parameters:
    tool: workflow_file_submit
    arguments: '{"file_ref":{"file_id":"<file-id>","owner_run_id":"<run-id>"},"slug":"my-upload-service","path":"/v1/upload"}'

transform — deterministic data ops from the allowlist above. replace and regex_replace are not implemented; never author them. Unknown names silently behave as identity in the current runtime, so validate the name and the output rather than trusting a green step.

- id: total
  type: transform
  parameters: { op: group_by, key: category, value: amount, aggregate: sum, precision: "2" }

For deterministic aggregation or JSON/report rendering that exceeds the fixed operations, use the typed bounded template transform. The input must be JSON and is exposed as data:

- id: summarize_items
  type: transform
  parameters:
    op: template
    template: >-
      {{ total = 0 }}
      {{ for item in data.items; total = total + number(item.amount); end }}
      {{ json({ count: data.items.size, total: round(total, 2) }) }}

json_parse — parse a JSON string selected by path into structured JSON.

- id: parse_embedded_json
  type: transform
  parameters:
    op: json_parse
    path: "$.payload"

assign — write a workflow variable (often the final output step).

- id: finalize
  type: assign
  parameters: { target: final_summary, value: "$input" }

conditional — two-way branch; set branches.true/branches.false. switch — multi-way branch on a value; set parameters.branch.<key> and branches, include _default.

- id: route
  type: switch
  parameters:
    on: "${steps.classify.json.category}"
    branch.urgent: handle_urgent
    branch._default: handle_normal
  branches: { urgent: handle_urgent, _default: handle_normal }

foreach — split input by delimiter, run a sub-step per item, merge.

- id: per_item
  type: foreach
  parameters:
    delimiter: "\n"
    sub_step_type: llm_call
    sub_target_role: worker
    sub_param_prompt_prefix: "Process item:"

For multiple workflow input files, use items_source: input_file_refs; each child step receives exactly one current file ref.

- id: extract_each_file
  type: foreach
  parameters:
    items_source: input_file_refs
    sub_step_type: tool_call
    sub_param_tool: document_extract
    sub_param_arguments: "{}"

Keep items_source, sub_step_type, and sub_param_tool under parameters; root-level items_source / sub_param_tool are not reliably lifted by the parser. Use sub_param_arguments: "{}" when the tool should read the per-item file ref instead of treating the file id input as arguments.

Each foreach child expands every sub_param_* value against that child, after fan-out. Use ${input} or ${output} inside sub_param_arguments, paths, or prompts to refer to the current item; both names resolve to the same per-item value at expansion time. Do not pre-expand those fields against the parent input.

Parallelism: concurrent fan-out → merge

Yes — the engine runs branches concurrently, and foreach / parallel / map_reduce / race are the primitives that express it. Each one dispatches its sub-steps in parallel (by default up to 20 at once, hard ceiling 200; set max_concurrent_workers to change the cap and min_concurrent_workers for a steady-state floor). The parent step then waits for all sub-steps and merges their text outputs joined by \n---\n. That fan-out → fan-in is the aevatar equivalent of a tool like n8n where many source branches feed one merge node.

There is no free-form DAG: you do not hand-draw N parallel branches into a merge node. Instead you pick the primitive whose built-in fan-out matches your shape:

You want…UseHow each branch is fed
One input, run by N workers concurrently, then optionally vote a winnerparallelevery worker gets the same $input
A list of items, run the same sub-step on each, then concatenateforeachthe input is split into items; each sub-step gets a different one
A list of items, process each, then synthesize all into one resultmap_reducesplit → map each (different item) → reduce the merged outputs once
N alternative attempts, take the first to finishraceevery branch gets the same $input; first success wins, the rest are discarded

The n8n "read N sources in parallel → merge" shape is a list → fan-out → merge, so it is foreach (concatenate the per-item results) or map_reduce (synthesize them into one) — not parallel. parallel and race feed the same input to every branch (ensemble / consensus / first-wins), not a different source per branch.

Where the item list comes from (foreach / map_reduce): the previous step's output, split by delimiter (default \n---\n) or parsed as a JSON array; or an explicit items: list; or items_source: input_file_refs (one file per item). Produce that list upstream — the run input, an assign, or transform op: rss_extract_items.

parallel — fan one input out to N llm_call workers, merge (optionally vote). Sub-steps are always llm_call; it needs either workers (distinct roles) or target_role + parallel_count.

- id: critique
  type: parallel
  parameters:
    workers: "reviewer_a,reviewer_b,reviewer_c"   # one llm_call per role; each gets the SAME $input
    # parallel_count: "3"            # alternative: N copies of target_role instead of distinct workers
    # max_concurrent_workers: "20"   # default 20, ceiling 200
    # vote_step_type: vote           # optional: aggregate the N outputs via a consensus rule (see the vote primitive)
  next: finalize

map_reduce — split into items, map each concurrently, then reduce the merged results into one output. The map phase carries no per-step parameters, so map is best for llm_call analysis driven by the map role's system_prompt; reduce_prompt_prefix is prepended to the merged outputs before the reduce step.

- id: analyze_all
  type: map_reduce
  parameters:
    delimiter: "\n---\n"             # how the input splits into items (or pass a JSON array)
    map_step_type: llm_call
    map_target_role: analyst         # analyzes every item concurrently — the fan-out
    reduce_step_type: llm_call
    reduce_target_role: synthesizer  # runs ONCE on the merged map outputs — the fan-in
    reduce_prompt_prefix: "Synthesize these analyses into one brief:"
  next: finalize

Omit the reduce_* fields and you get just the merged map outputs (no synthesis) — that is equivalent to foreach.

Full primitive vocabulary (use advanced ones only when needed)

GroupTypes
AIllm_call, tool_call, evaluate (score+threshold), reflect
Datatransform, assign, retrieve_facts, cache
Controlguard/assert, conditional, switch, while/loop, delay/sleep, lease/mutex, wait_signal, checkpoint
Compositionforeach, parallel/fan_out, race, map_reduce, workflow_call, dynamic_workflow, vote
Integrationconnector_call (aliases: http_get, http_post, http_put, http_delete, mcp_call, cli_call), emit/publish
Humanhuman_input, human_approval, wait_signal

Advanced notes: human_approval/wait_signal suspend the run until a resume/signal event — use them for approvals and long external waits instead of stretching a step past its 600s executor limit. parallel/foreach/map_reduce accept min_concurrent_workers/max_concurrent_workers (see Parallelism: concurrent fan-out → merge above for which one to pick and the concurrency defaults). Side-effecting steps may declare compensation: <step_id> for saga rollback.

Interpolation

  • $input — the current step's input (the previous step's output, or — for the FIRST step — the run prompt). This is how a value flows step-to-step.
  • ${steps.<id>.output} — a prior step's text output. It is .output, NOT .text. The engine registers steps.<id>.output and never steps.<id>.text, so ${steps.<id>.text} silently resolves to an empty string — the run still shows every step "completed", but a tool/connector downstream receives an empty argument and fails.
  • ${<name>} — a workflow variable written by an assign step (target: <name>). This is the canonical way to read a captured value back in a later step; ${steps.<capture-id>.text} does NOT work (use the bare ${<name>}, or equivalently ${steps.<capture-id>.output}).
  • ${steps.<id>.json.<field>} — a field from a prior step whose output was a JSON object (e.g. a tool_call result). Also: ${steps.<id>.success}, ${steps.<id>.error}, ${steps.<id>.annotations.<ns>.<key>}.
  • Expression functions (usable in any value, incl. condition): if, concat, isblank, length, not, and, or, upper, lower, trim, json, add, sub, mul, div, eq, lt, lte, gt. There is no contains, replace, regex, or substring function.

Gotchas that silently break runs (verified against the engine — a clean test run does NOT catch these, because failed tool calls return their error as ordinary step output):

  • ${steps.<id>.text} is always empty — use ${steps.<id>.output}. This is the #1 cause of "every step completed but the connector got an empty argument."
  • Read an assigned value with the bare ${<target>}, not ${steps.<capture-id>.text}.
  • transform op: split joins all parts with \n---\n and ignores any index — it is for fan-out, not single-element extraction. To use one segment of a/b (e.g. an owner/repo in a path), pass the whole string where the / is already correct rather than splitting it apart.
  • conditional.condition is interpolated first; if the result is not literally true/false, the engine does a substring $input.Contains(condition). Since there is no contains function, build "any/all contain token" checks around this: concat the inputs into one string in the prior step, then set condition to the literal token.
  • parallel (and race) feed every branch the same $input and each sub-step is always an llm_call. For different input per branch — the "N different sources" shape — split a list with foreach / map_reduce instead. All four merge sub-step outputs with \n---\n. map_reduce's map sub-steps receive no per-step parameters (drive them via the map role's system_prompt); only foreach passes sub_param_* to each child, so per-item tool_call fetches must use foreach.
  • switch.on that resolves to empty silently takes the WRONG branch. switch evaluates on first; if it resolves to empty — e.g. ${steps.read.json.off_count} when that field is absent, which is exactly what happens when the prior tool returned an error envelope ({"error":true,"status":503,...}) instead of the expected object — switch falls back to matching the step's whole $input, and matching is exact → substring-contains → _default. So a 503 error body substring-matches a branch key "0" (via "503"/"8001") and quietly routes as if the value were 0. Always make on a clean, never-empty token: on: "${eq(steps.read.json.off_count, '0')}" with branches "true" / "false" / _default. Never branch on a raw count/field that can go missing.
  • A side-effecting workflow MUST read back and assert the real end state — a step "success" does NOT mean the side effect happened. Because a failed tool call returns its error as ordinary step output (above), an action step like turn_on / post / create completes "successfully" even when nothing changed (e.g. the downstream returned 503). Pattern: after the action, add a tool_call that reads the real state (have the upstream API compute a scalar you can branch on — e.g. an HA /template returning {"off_count":N}), switch on ${eq(<count>,'0')}; on the not-yet-satisfied branch delay + re-read for a bounded retry, and on final failure route to a guard with on_fail: fail so the run actually fails (red in the observatory) instead of a green run that did nothing. Do not trust ${steps.<action>.success}.

Accessing external services

There is a raw current-turn proxy surface, two compiled workflow capability shapes, and a separate host-connector subsystem. Do not mix their fields.

  • Raw current-turn nyxid_proxy. First select an exact UserService instance. The call requires service_id + slug + path; method defaults to GET. Optional fields are body, non-sensitive headers, and response_mode:
    {
      "service_id": "<selected-user-service-id>",
      "slug": "<service-slug>",
      "path": "/v1/resource",
      "method": "POST",
      "body": {"k": "v"}
    }
    
    This is a direct current-turn tool call, not workflow YAML.
  • No dynamic current-turn operation tools. nyxid_service_operation__* and nyxid_service_request are retired. Never invent or cache one of those tool names. Current-turn management uses the typed connected-service tools; raw one-off HTTP uses nyxid_proxy only where that surface is explicitly available.
  • Compiled published operation. Call list_external_workflow_capabilities, copy the exact descriptor selector, inspect it for the required execution mode, and persist it beside the step as capability.nyxid_operation. endpoint_id is the authored operation selector; operation_id is reserved/absent from this YAML/proto contract:
    {
      "selector": {
        "nyx_id_operation": {
          "user_service_id": "us-lark-7",
          "endpoint_id": "get-message"
        }
      },
      "execution_mode": "interactive"
    }
    
    Pass that whole object to inspect_external_workflow_capability_readiness; nyx_id_operation is required and must not be flattened.
    - id: fetch_message
      type: tool_call
      capability:
        nyxid_operation:
          user_service_id: us-lark-7
          endpoint_id: get-message
      parameters:
        tool: nyxid_proxy
        arguments: '{"path_params":{"message_id":"m-42"}}'
    
    The compiler's admission proof owns UserService identity, slug, internal operation identity, method, path template, digest, schemas, and response policy. Runtime arguments may contain only admitted path_params, query, headers, body, and response_mode. Do not send service_id, user_service_id, slug, path, method, endpoint_id, operation_id, or a contract digest in arguments.
  • Operation discovery is surface-dependent. list_external_workflow_capabilities is an in-session tool, not a public REST endpoint. /api/v1/keys proves exact UserService identity but does not replace the workflow operation list. If the client surface exposes neither the list tool nor an operation picker, do not probe invented REST paths or guess an endpoint. Report operation discovery unavailable and require an exact selector from an authoritative service contract or a supported surface before binding.
  • Compiled authored HTTP request. Use capability.nyxid_request only for an exact request contract that cannot be selected as a published operation. Author the typed method, relative path template, allowed request fields, and response policy; select one exact UserService from /api/v1/keys. Binding must preview the current request-contract digest and risk, then obtain the authenticated binder's explicit confirmation/grant. Saving a draft, preview success, or /api/v1/user-services cannot grant execution. Authored requests never rediscover inventory or OpenAPI at runtime; they execute only the committed proof and matching grant. A step capability is a oneof: do not put nyxid_request beside nyxid_operation on the same step. Different steps may use different shapes when each is admitted independently.
    capability:
      nyxid_request:
        user_service_id: us-lark-7
        method: GET
        path_template: /open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}
        query_parameters: [user_id_type]
        body_mode: none
        response_mode: text
    
  • Durable scheduling inspects every call site. A sample input that chooses a preview branch does not remove a write call site from the definition. On a host without scheduled operation authority, any authored non-read request makes Team automation preflight fail closed even when the prompt says submit:false. To prove scheduling safely, publish a separate preview-only revision with the write steps and their branch targets physically removed. Do not weaken the original workflow or route it through generic scheduling to bypass admission.
  • Registered workflow connectors. If the capability is a connector registered in the workflow connector registry, call it with connector_call and authorize it on the role:
    roles:
      - id: caller
        name: Caller
        connectors: [my_connector]
    steps:
      - id: call
        type: connector_call
        target_role: caller
        parameters: { connector: "my_connector", operation: "list", path: "/v1/items", timeout_ms: "10000" }
    
  • allowed_tools gotcha. A role with no allowed_tools sees the full inherited tool catalog (including nyxid_proxy). But the moment you set allowed_tools on a role, you must list every tool its steps call (e.g. allowed_tools: [nyxid_proxy]) — otherwise the tool_call will not resolve the tool at run time.
  • Prefer a typed tool when one exists for the capability (they expose stable control fields and validation) over a hand-built proxy path.
  • Missing or invisible service. Use nyxid_require_service for an in-session connect/add/authorize request, or the supported NyxID CLI/console flow as a client. USER_SERVICE_NOT_VISIBLE does not prove that a simple registration retry will fix the account, and /api/auth/nyxid/authorization-catalog:refresh is reconciliation rather than service registration. If refresh returns api_key_scope_plan_route_unresolved, stop and report the catalog blocker; do not delete/reconnect unrelated services or guess which route caused it. org_role_insufficient requires an authorized organization operator. Never fabricate a slug, connector, or self-service path.
  • Preflight only the downstream access the workflow actually uses. Binding/readiness does not test provider data ACLs. If a Lark Base workflow reads a record, run one explicitly authorized read-only call against a known sample record_id with the same UserService, Base/app token, table, and admitted read contract; require expected fields, not only 2xx. 91403 means the exact Bot application lacks Base document access; 1254302 RolePermNotAllow means its advanced-permission role does not cover the target table/action. If a source-verified automation payload already carries every required field and the workflow makes no Base API call, do not add a read capability or demand Base document/role access. This probe proves only the read path it exercises, not trigger authenticity, durable admission, or write authority.

Validating and proving a run

Dispatch one test run with aevatar_start_workflow, passing the draft inline:

{ "workflow_id": "<name>", "workflow_yamls": ["<full yaml>"], "inputs": { "prompt": "<test input>" } }

If the definition consumes structured run input (for example it captures $input and applies json or feeds it to a transform), inputs.prompt must be a non-empty serialized JSON string, such as "{\"submit\":false}". Never pass natural-language prose, an empty string, or an unserialized object to a typed workflow. Free-text workflows that do not declare structured input keep their normal prompt semantics.

workflow_id is required; inputs is an object (typically { "prompt": "..." }, optionally input_parts / headers). File-bearing calls may carry normalized inlineFile or fileRef parts; member stream invocation and draft-run share the same ingress normalization, so preserve the typed file part instead of flattening it into prompt text.

Interpret evidence in order:

  • accepted/streaming means only that dispatch started.
  • After acceptance, require the first projection-backed business frame within 30 seconds. SSE : keepalive is transport-only and does not satisfy or extend this deadline. If the stream closes with root RUN_ERROR(code=RUN_OBSERVATION_TIMEOUT), observation timed out, not necessarily the workflow itself; query the same actorId + commandId for later state and never create another run as a status probe.
  • Only root RUN_FINISHED and root RUN_ERROR are terminal for the workflow stream. Role text, reasoning, tool-call, tool-result, or role-level terminal frames are progress and must not end observation.
  • A typed parse, admission, identity, or resource-limit error is a structural failure; fix the document before another run.
  • For the same run, inspect terminal completion, run detail, and audit. Success requires completed terminal state, lastSuccess=true, expected non-empty step output, and non-empty final output.
  • On failure, locate the first failed audit step and classify it before retrying. For NyxID HTTP auth failures, distinguish missing caller credential propagation, missing executor propagation, wrong exact route/credential selection, and downstream UserService authorization.
  • Never start another run merely to check whether the previous run finished.

Persisting (make it reusable)

Once the draft dispatches without a parse error, publish a private skill that carries the workflow:

{
  "name": "<kebab-case-workflow-name>",
  "description": "<one line: what it does>",
  "version": "1.0",
  "category": "runtime-based",
  "instructions_markdown": "Runs the <name> workflow. Invoke with use_skill then aevatar_start_workflow; inputs: <list>.",
  "workflow_yamls": [ { "workflow_id": "<name>", "content": "<full yaml>" } ]
}

Choose a clear name/description so the user (and future searches) can find it. Publishing is private by default; the user can later promote it to public on the platform.

Re-run later: the user (or their model) loads it with use_skill("<name>") — which mounts the workflow into their scope — then calls aevatar_start_workflow with workflow_id: "<name>". The run goes through the normal engine path and is visible in the observatory.


Client REST path

When the server-side Aevatar/NyxID tools are absent, read references/client-rest.md before validating, publishing, or invoking through the NyxID broker.

Worked examples

Read references/worked-examples.md when you need complete YAML shapes for branching, per-item calls, typed files, bounded templates, or fan-out/reduce.

Self-check before publishing

  • Final step is last in the document and has no next; every branch reaches it via explicit next.
  • Any date/time the logic needs is injected via input, not assumed.
  • Typed/structured run input is passed as one non-empty serialized JSON string; schedule and webhook dates come from their documented ingress-time templates and explicit timezone.
  • Every externally pushed field has an explicit ingress authority decision; unverified input is treated as a claim/locator, while trusted input is bound to a dedicated authenticated source and the delivery/idempotency contract is explicit.
  • No hardcoded model: unless the user demanded one.
  • Arithmetic / totals / dedup use transform, not llm_call.
  • Every transform op is on the explicit allowlist; no replace/regex_replace or other unknown identity fallback is present, and required transformed output is asserted.
  • Every workflow external call copied an exact listed user_service_id + endpoint_id selector (or carries one independently admitted nyxid_request), passed readiness for its execution mode, and puts only runtime operation values in nyxid_proxy.arguments; raw current-turn proxy calls use exact service_id + slug + path.
  • One authorized dispatch was observed to root RUN_FINISHED / RUN_ERROR, or a RUN_OBSERVATION_TIMEOUT was reconciled by querying the same actorId + commandId; no second run was created as a status check.
  • Any parallel fan-out uses the right primitive: same input → parallel / race; a list of different items → foreach (concatenate) or map_reduce (synthesize). Per-item tool_call fetches use foreach, not map_reduce.

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.