diff --git a/examples/auth-flow.yaml b/examples/auth-flow.yaml index 0bd4662..66171a9 100644 --- a/examples/auth-flow.yaml +++ b/examples/auth-flow.yaml @@ -25,14 +25,14 @@ flow: steps: - from: browser to: app - data: GET /login + data: The user clicks "Sign in"; the browser asks the app server for the start of the login flow. - from: app to: oauth - data: Redirect to Google + data: The app sends the browser over to Google so the user can sign in with their Google account. - from: oauth to: app data: - label: OAuth callback + label: Google bounces the browser back to the app with a one-time authorization code and the original state value so the app can prove this came from the same login attempt. fields: - name: code type: string @@ -40,11 +40,11 @@ flow: type: string - from: app to: oauth - data: Exchange code for token + data: The app trades the one-time code with Google for the real tokens that represent the signed-in user. - from: oauth to: app data: - label: Token response + label: Google answers with the user's access token, identity token, and email address so the app knows who just signed in. fields: - name: access_token - name: id_token @@ -52,18 +52,18 @@ flow: added: true - from: app to: [db, cache] - data: Store user + session + data: The app saves the user record in the database and opens a session in the cache so subsequent requests don't have to re-authenticate. - parallel: - from: db to: app - data: user_id + data: The database confirms the write and hands the app this user's stable identifier. - from: cache to: app - data: session_id + data: The session cache returns the session identifier the app will use as the cookie value. - from: app to: browser data: - label: Set cookie + redirect + label: The app sets a session cookie on the browser and redirects the user to their original destination. fields: - name: session_id - name: redirect_to diff --git a/examples/create-destroy.yaml b/examples/create-destroy.yaml index 7aeab93..bc5a327 100644 --- a/examples/create-destroy.yaml +++ b/examples/create-destroy.yaml @@ -16,7 +16,7 @@ flow: # Step 1: Queue feeds a job to the dispatcher. - from: queue to: dispatcher - data: dequeued job + data: The dispatcher pulls the next pending job off the queue to process it. # Step 2: Dispatcher spawns a Worker for this job. The Worker # node doesn't exist in `nodes` above — `create:` brings it @@ -29,7 +29,7 @@ flow: label: Worker type: service data: - label: spawn + label: The dispatcher spins up a brand new worker process and hands it this job to handle. fields: - name: job_id type: string @@ -38,7 +38,7 @@ flow: - from: worker to: dispatcher data: - label: result + label: The worker finishes the job and reports the outcome back to the dispatcher. fields: - name: status type: string diff --git a/examples/order-flow.yaml b/examples/order-flow.yaml index 5d579f4..073a735 100644 --- a/examples/order-flow.yaml +++ b/examples/order-flow.yaml @@ -32,7 +32,7 @@ flow: - from: validate to: enrich data: - label: validated order + label: The validator hands the cleaned-up order to enrichment so missing pricing and tax details can be filled in. fields: - name: items type: "list[OrderItem]" @@ -67,7 +67,7 @@ flow: - from: user to: api data: - label: POST /orders + label: The shopper submits a new order with their cart items, account ID, and any coupon they're applying. fields: - name: items type: "list[OrderItem]" @@ -79,7 +79,7 @@ flow: - from: api to: authz data: - label: validate session + label: The gateway forwards the user's session credentials to the auth service to confirm they're really signed in. fields: - name: auth_token type: string @@ -89,7 +89,7 @@ flow: - from: authz to: api data: - label: auth context + label: The auth service confirms the session is valid and hands back the user's roles so the gateway can decide what they're allowed to do. fields: - name: user_id type: int @@ -103,7 +103,7 @@ flow: - from: api to: rate-limit data: - label: rate limit check + label: The gateway asks the rate-limit service whether this user has any quota left for placing orders. fields: - name: user_id type: int @@ -113,7 +113,7 @@ flow: - from: rate-limit to: api data: - label: quota approved + label: The rate-limit service approves the request and reports how many orders this user can still place in the current window. fields: - name: remaining_requests type: int @@ -123,7 +123,7 @@ flow: - from: api to: order-service data: - label: create order + label: The gateway hands the actual order details to the order service so the real processing can begin. fields: - name: items type: "list[OrderItem]" @@ -134,7 +134,7 @@ flow: - from: authz to: order-service data: - label: auth claims + label: At the same time, the auth service forwards the user's verified identity and roles directly to the order service so it doesn't have to re-check. fields: - name: user_id type: int @@ -146,7 +146,7 @@ flow: - from: api to: order-service data: - label: gateway trace + label: The gateway also attaches a trace identifier and the edge region it came from so the order service can correlate logs across the request. fields: - name: request_id type: string @@ -158,7 +158,7 @@ flow: - from: order-service to: [db, cache] data: - label: persist order + label: The order service persists the finalized order to Postgres and warms the Redis cache so future status lookups are fast. fields: - name: order type: Order @@ -172,7 +172,7 @@ flow: - from: db to: order-service data: - label: order saved + label: Postgres confirms the write and returns the new order's identifier and creation timestamp. fields: - name: order_id type: int @@ -183,7 +183,7 @@ flow: - from: cache to: order-service data: - label: cache updated + label: Redis confirms the cache entry was stored and is ready to serve. fields: - name: cached type: bool @@ -191,13 +191,13 @@ flow: - from: order-service to: payment data: - - label: charge request + - label: The order service asks Stripe to charge the customer's card for the order total. fields: - name: amount type: float - name: currency type: string - - label: order context + - label: Alongside the charge, the order service sends the order's context so Stripe can attach it to the transaction for reconciliation later. fields: - name: order_id type: int @@ -207,7 +207,7 @@ flow: - from: payment to: order-service data: - - label: payment result + - label: Stripe answers with the result of the charge — the new payment identifier and whether it succeeded. fields: - name: payment_id type: string @@ -215,7 +215,7 @@ flow: - name: status type: string added: true - - label: transaction details + - label: Along with the result, Stripe returns the financial breakdown so the merchant knows how much actually landed in their account after fees. fields: - name: amount type: float @@ -235,7 +235,7 @@ flow: label: Audit Log type: service data: - label: log order event + label: The order service spins up a temporary audit logger for this specific transaction so the compliance trail can be written without blocking the response. fields: - name: order_id type: int @@ -252,7 +252,7 @@ flow: - from: order-service to: events data: - label: publish order.created + label: The order service publishes an "order created" event to the queue so downstream systems like fulfillment, analytics, and email can react asynchronously. fields: - name: order_id type: int @@ -265,7 +265,7 @@ flow: - from: cron to: order-service data: - label: retry stuck orders + label: On a fixed schedule, the retry scheduler sweeps the order service for orders that got stuck and asks it to try them again. fields: - name: since type: datetime @@ -275,7 +275,7 @@ flow: - from: order-service to: api data: - label: order response + label: The order service tells the gateway the order is done and returns everything the user needs to see — order ID, total, status, and payment reference. fields: - name: order_id type: int @@ -289,7 +289,7 @@ flow: - from: api to: user data: - label: "201 Created" + label: The gateway responds to the shopper confirming their order was placed successfully and shows them the order details. fields: - name: order_id type: int diff --git a/examples/parallel.yaml b/examples/parallel.yaml new file mode 100644 index 0000000..bdac369 --- /dev/null +++ b/examples/parallel.yaml @@ -0,0 +1,94 @@ +meta: + title: Parallel Fan-out + path: examples/parallel + description: A checkout handler that fires two independent calls at the same tick — reservation + payment — and waits for both to come back before responding + +flow: + nodes: + - id: client + label: Client + type: actor + - id: api + label: API + type: endpoint + - id: inventory + label: Inventory + type: service + - id: payment + label: Stripe + type: external + icon: "logos:stripe" + color: "#635BFF" + + steps: + - from: client + to: api + data: + label: The shopper kicks off checkout — the API receives the cart items and a one-time payment token together. + fields: + - name: items + type: "list[CartItem]" + - name: card_token + type: string + + # Parallel: the API doesn't wait. Both calls leave at the same + # tick — inventory reservation and payment authorization fire + # together. The single-arm version would serialize them; this + # halves the wall-clock for the checkout request. + - parallel: + - from: api + to: inventory + data: + label: The API asks the inventory service to reserve the items in the cart so they can't be oversold while the payment authorization runs. + fields: + - name: items + type: "list[CartItem]" + - name: hold_seconds + type: int + - from: api + to: payment + data: + label: At the same instant the API asks Stripe to authorize the customer's card for the order total. + fields: + - name: amount + type: float + - name: currency + type: string + + # Both responses arrive in parallel. The renderer fires the two + # pixels concurrently so you can see "neither side was waiting on + # the other" at a glance. The API can't respond to the client + # until both have landed. + - parallel: + - from: inventory + to: api + data: + label: Inventory confirms the reservation went through and tells the API how long the hold is valid before the items get released back to general stock. + fields: + - name: reservation_id + type: string + added: true + - name: expires_at + type: datetime + added: true + - from: payment + to: api + data: + label: Stripe confirms the authorization succeeded and returns the payment identifier the API will reference when the order eventually settles. + fields: + - name: payment_id + type: string + added: true + - name: status + type: string + added: true + + - from: api + to: client + data: + label: With both the reservation and the payment authorization in hand, the API responds to the shopper that checkout completed and returns the new order details. + fields: + - name: order_id + type: int + - name: total + type: float diff --git a/examples/self-loops.yaml b/examples/self-loops.yaml index 56636dc..9a28975 100644 --- a/examples/self-loops.yaml +++ b/examples/self-loops.yaml @@ -24,22 +24,22 @@ flow: steps: - from: client to: api - data: POST /jobs + data: The client submits a new background job to the API gateway and asks it to be queued. # Self-loop with multiple data payloads sent at once — three independent # checks fire on the same internal step. - from: api to: api data: - - label: schema validation + - label: The API runs schema validation on the incoming body to make sure the request is well-formed. fields: - name: body type: JSON - - label: auth check + - label: The API verifies the caller's token to confirm they're allowed to enqueue this job. fields: - name: token type: JWT - - label: rate limit + - label: The API checks its rate-limit bucket for this caller so a single client can't overwhelm the workers. fields: - name: bucket type: string @@ -47,20 +47,20 @@ flow: # Broadcast: API fans out to both worker and cache in a single step. - from: api to: [worker, cache] - data: enqueued job + data: The API hands the job to a worker for processing and stashes a pending record in the cache for fast status lookups. # Self-loop with multi-data: worker reports retry + metric in parallel. - from: worker to: worker data: - - label: process batch (retry x3) + - label: The worker processes the batch, retrying up to three times with exponential backoff if a step fails. fields: - name: attempt type: int changed: true - name: backoff_ms type: int - - label: emit metrics + - label: While processing, the worker emits internal timing and throughput metrics for the observability stack. fields: - name: latency_ms type: float @@ -70,12 +70,12 @@ flow: # Broadcast write: worker writes to db and invalidates cache at once. - from: worker to: [db, cache] - data: write result + data: The worker writes the final result to the database and invalidates the cache entry so the next reader gets the fresh data. # Self-loop on the database: trigger fires a follow-up update. - from: db to: db - data: trigger update_audit_log + data: A database trigger fires automatically and updates the audit log to record this change. # Multiple sources delivering to the same node at once — worker reports # completion while cache confirms invalidation; api receives both in parallel. @@ -83,7 +83,7 @@ flow: - from: worker to: api data: - label: job complete + label: The worker tells the API the job has finished and reports how long it took. fields: - name: job_id type: uuid @@ -92,10 +92,10 @@ flow: - from: cache to: api data: - label: cache invalidated + label: The cache confirms back to the API how many stale entries were evicted as part of the invalidation. fields: - name: keys_evicted type: int - from: api to: client - data: "200 OK" + data: The API tells the client the job completed successfully and returns a 200. diff --git a/examples/simple-crud.yaml b/examples/simple-crud.yaml index 0a16df2..af28255 100644 --- a/examples/simple-crud.yaml +++ b/examples/simple-crud.yaml @@ -16,15 +16,38 @@ flow: type: database steps: + # Step data has two forms: a bare string (just the narration) and + # an object with `label:` + structured `fields:`. The string form + # below is the simplest; the next step shows the full object form + # with fields so the renderer's tooltip lists each field, type, + # and any add/remove/change diff markers. - from: client to: api - data: POST /items + data: The client submits a new item with its name and quantity to the REST API. - from: api to: db - data: INSERT item + data: + label: The API translates the incoming request into a database insert and writes a new row to the items table. + fields: + - name: name + type: string + - name: quantity + type: int + - name: created_at + type: datetime + added: true - from: db to: api - data: created item + data: + label: The database returns the newly created row along with its generated identifier so the API can echo it back to the caller. + fields: + - name: id + type: int + added: true + - name: name + type: string + - name: quantity + type: int - from: api to: client - data: "201 Created" + data: The API confirms back to the client that the item was created successfully and returns the new item's ID. diff --git a/examples/sub-flows.yaml b/examples/sub-flows.yaml index 6ae6b27..abc6091 100644 --- a/examples/sub-flows.yaml +++ b/examples/sub-flows.yaml @@ -32,11 +32,11 @@ flow: steps: - from: validate to: enrich - data: clean request + data: The validator hands the cleaned-up request off to enrichment so missing fields can be filled in. - from: enrich to: persist data: - label: enriched record + label: Enrichment passes the fully-populated record to persistence so it can be written to long-term storage. fields: - name: items type: list @@ -51,23 +51,23 @@ flow: steps: - from: client to: api - data: POST /orders + data: The client submits a new order with the cart items and shipping address. # `drilldown: true` auto-opens the sub-flow during playback when # the camera lands on this step. Use sparingly — once per flow # is usually enough; chained drilldowns get disorienting. - from: api to: order-service - data: validated input + data: The API hands the request to the order service for the real processing work. drilldown: true - from: order-service to: db - data: persist row + data: The order service writes the finalized order to the database. - from: db to: order-service - data: order_id + data: The database confirms the write and returns the new order's identifier. - from: order-service to: api - data: 201 Created + data: The order service tells the API that the order was placed and returns its ID. - from: api to: client - data: order created + data: The API confirms back to the client that their order was created successfully. diff --git a/examples/type-variants.yaml b/examples/type-variants.yaml index 46d1e1c..1c38811 100644 --- a/examples/type-variants.yaml +++ b/examples/type-variants.yaml @@ -92,6 +92,11 @@ flow: steps: # Minimal connective tissue so nodes aren't orphans and ELK has something # to lay out. Chain the first of each type to the next type's first. + # + # NOTE: The `data: "→"` placeholders below are deliberately NOT the + # verbose plain-English narration the skill recommends for real flows + # — this file is a sprite/type showcase, not a narrative walkthrough. + # Don't copy this terse style into a story-driven flow. - { from: actor-1, to: endpoint-1, data: "→" } - { from: endpoint-1, to: auth-1, data: "→" } - { from: auth-1, to: db-1, data: "→" } diff --git a/skills/openhop/SKILL.md b/skills/openhop/SKILL.md index d7c1a14..c787b50 100644 --- a/skills/openhop/SKILL.md +++ b/skills/openhop/SKILL.md @@ -40,6 +40,7 @@ Each row reuses one of the bundled `examples/*.yaml` flows so the inputs match w | "diagram a minimal CRUD service" | `examples/simple-crud.yaml` | `http://localhost:8788/flow/` | | "I want to see every node type in one picture" | `examples/type-variants.yaml` | `http://localhost:8788/flow/` | | "how do retries / internal work loops on a single node" | `examples/self-loops.yaml` | `http://localhost:8788/flow/` | +| "show me two things happening at the same time" | `examples/parallel.yaml` | `http://localhost:8788/flow/` | | "show me a worker that's spawned and then destroyed" | `examples/create-destroy.yaml` | `http://localhost:8788/flow/` | | "diagram a service whose internals are themselves a flow" | `examples/sub-flows.yaml` | `http://localhost:8788/flow/` | | "visualize a three-tier app (browser → API → DB)" | the YAML in "Quickest valid flow" below | `http://localhost:8788/flow/` | @@ -67,16 +68,16 @@ flow: steps: - from: browser to: api - data: request + data: The user opens the home page; the browser sends the initial page request to the API server. - from: api to: db - data: query + data: The API asks the database for the rows it needs to render the page for this user. - from: db to: api - data: rows + data: The database returns the matching rows along with the row count. - from: api to: browser - data: response + data: The API renders the page and sends the finished HTML back to the browser. ``` Push it with `openhop push --json` (or `openhop push - --json` for stdin). Parse the JSON response and return the `url` field to the user. @@ -90,6 +91,30 @@ Push it with `openhop push --json` (or `openhop push - --json` for stdin) If the validator rejects your flow, **read the error path** — it tells you exactly which field is wrong. +## Voice — short on node names, verbose on step text + +The two text channels in a flow carry opposite weights and should be written in opposite voices. + +- **Node labels are billboards.** They're short, noun-phrase, identity-only. ≤ 4 words. **No code names.** ✓ `Order Service`, `Auth API`, `Postgres`, `Stripe`. ✗ `order_service_v2`, `OrderProcessingHandler`, `auth-jwt-mw`. +- **Step `data` labels are the narration.** The user reads them on hover and follows them through playback. Be verbose. Use plain English. Explain **what is happening in the world**, not the wire format. **No code names**, no HTTP verbs, no SQL, no method signatures. + +The agent's job here is to **narrate** the flow, not annotate the protocol. The user is asking "walk me through what happens" — answer that question in sentences, not in routes. + +**Write step data labels like this:** + +| ✗ Too terse, code-flavored | ✓ Verbose, plain English narration | +| ---------------------------- | -------------------------------------------------------------------------------------------- | +| `POST /orders` | `The user submits a new order with their cart items and shipping details.` | +| `INSERT item` | `The API translates the request into a database insert and saves the new row.` | +| `SELECT * WHERE user_id = ?` | `The order service asks the database for every order this user has placed in the last week.` | +| `query` | `The API asks the database to look up the matching record.` | +| `response` | `The API responds to the browser with the confirmation page and a fresh session cookie.` | +| `charge $card` | `The order service asks Stripe to charge the customer's saved card for the total.` | +| `auth ok` | `The auth service confirms the token is valid and tells the API who the user is.` | +| `redis.get(session)` | `The API checks the session cache to see if this user is already signed in.` | + +The rule applies whether `data` is the string shorthand (`data: "..."`) or the object form's `label:` field. The `fields:` array still uses code-flavored names + types (`{ name: items, type: list[OrderItem] }`) — that's a schema, not narration, so it stays technical. + ## Before Creating Flows If `openhop --version` fails with `command not found`, OpenHop's CLI isn't installed yet. Run `npx openhop init` yourself to install it, then continue with the steps below. (`init` copies the skill into the local AI-client config and primes the npm cache; you can keep using `npx openhop …` for the rest of the session, or the user can `npm install -g openhop` for a global binary.) @@ -299,7 +324,7 @@ Each node type has common real-world variants. Use them to choose an accurate `l Either a move step, parallel, create, or destroy: - Move: `{ from, to (string or string[]), data (string or object), drilldown (bool) }` -- Parallel: `{ parallel: [move steps] }` (min 2). All sub-steps fire **concurrently** on playback — pixels travel at the same time. Use this when two or more transfers logically happen together, e.g. an orchestrator fans out work to several services at once, or two upstream nodes deliver payloads to the same target in the same tick. Each sub-step is itself a move (`from`/`to`/`data`). See [`examples/self-loops.yaml`](../../examples/self-loops.yaml) and [`examples/order-flow.yaml`](../../examples/order-flow.yaml) for in-context use. +- Parallel: `{ parallel: [move steps] }` (min 2). All sub-steps fire **concurrently** on playback — pixels travel at the same time. Use this when two or more transfers logically happen together, e.g. an orchestrator fans out work to several services at once, or two upstream nodes deliver payloads to the same target in the same tick. Each sub-step is itself a move (`from`/`to`/`data`). See [`examples/parallel.yaml`](../../examples/parallel.yaml) for an isolated demo and [`examples/self-loops.yaml`](../../examples/self-loops.yaml) / [`examples/order-flow.yaml`](../../examples/order-flow.yaml) for in-context use. ```yaml - parallel: