2つの顔、1つの真実(原則 T1)。このページでは両方のサーフェスを詳しく規定し、同じFlowをfluent SDK、YAML、そして両者がコンパイルされるFlowSpecの3通りで示すことで、それらが等価であることを証明します。
基本となる約束: コードで書いてもYAMLで書いても、成果物は同じであり、どちらからでももう一方を再生成できます。
1. fluent SDK(メソッドチェーン)
SDKは、FlowSpecを蓄積し、進めるたびにすべての配線を型チェックするビルダーです。これはクラスではなくファクトリ関数(ハウススタイル)であり、純粋です。ビルダーはI/Oを保持せず、記述するだけです。
import { flow, http, browser, transform } from "@browserflow/sdk";
import { z } from "zod";
export default flow("daily-price-watch")
// 型付き入力コントラクト。`secret: true` スロットは参照としてのみ運ばれる。
.input("query", z.string())
.input("threshold", z.number())
.input("webhook", z.string().url(), { secret: true })
// 手順1: バージョン固定された決定論的なbrowser toolを呼び出す。
.step("lookup", browser.tool("search-price@1.2.0").with((c) => ({ query: c.input.query })))
// 手順2: 純粋なtransform — 決定論的なJSON → JSON、capabilityなし。
.step("shape", transform((c) => ({ price: c.steps.lookup.output, at: c.now })))
// 手順3: 条件付き通知。branch述語がtruthyの場合だけ実行される。
.branch("dropped?", (c) => c.steps.shape.output.price < c.input.threshold, {
yes: (f) =>
f.step("notify", http.post(
(c) => c.secret.webhook,
(c) => c.steps.shape.output,
)),
no: (f) => f.noop("unchanged"),
})
// 型付き出力射影。
.output((c) => ({ price: c.steps.shape.output.price }));
ビルダーが保証すること
- 型安全な配線。
c.steps.lookup.output は search-price の ToolContract 出力スキーマから型付けされます。タイプミスやshapeの不一致はコンパイルエラーです。
- 依存関係の推論。
shape の中で c.steps.lookup.output を参照すると、エッジ lookup → shape が作られます。手で needs を書くことはほとんどありません。
- 暗黙の作用がないこと。
c.now、c.env、c.secret は注入されたコンテキストのメンバーであり、グローバルではありません(原則 T2)。transform 手順は c.fetch にアクセスできません。effectfulなToolを使う手順だけがアクセスできます。c.random は存在しません。非決定性はクロージャではなく、必ず記録されたソースから来なければなりません(下記参照)。
- 純粋な記述。 ビルダーを呼んでも作業は実行されず、ネットワークにも触れません。FlowSpecへ
.compile() できる FlowDefinition を返します。
コンテキストオブジェクト c
すべてのbinding closureは、読み取り専用の c を1つ受け取ります。
| メンバー | 型 | 意味 |
|---|
c.input | typed | Flowの検証済み入力 |
c.steps.<id>.output | typed | 完了した手順の出力 |
c.env.<KEY> | string | 許可リストに入った環境値(capability env) |
c.secret.<KEY> | ref | シークレットの参照。secrets capabilityを持つToolの内部でだけ解決される |
c.now | string | 注入された時計(リプレイ時は決定論的) |
c.run.id | string | 現在のRun id |
c.item | typed | foreach 手順の内部: 現在の配列要素 |
c.index | number | foreach 手順の内部: 現在の要素のインデックス |
c.random() はbinding closureの中では利用できません。SDKのlowering passは
c.random( を呼び出すclosure bodyをすべて拒否し、ビルド時に SdkError を投げます。
コンパイル済みFlowSpecに到達することはありません。非決定性は代わりに記録されたソース
から来なければなりません。時刻には now 参照(注入された時計)を使い、新しいidには
std/control の uuid のようなTool(出力がjournalされ、リプレイされるもの)を使います。
2. YAMLサーフェス
YAMLは、人間が読みやすい単一の信頼できる情報源です。永続化し、Gitでレビューするための形式です。これはFlowSpecの1:1の射影であり、配線のための式言語を備えています。
flow: daily-price-watch
inputs:
- { name: query, type: string }
- { name: threshold, type: number }
- { name: webhook, type: string, secret: true }
steps:
- id: lookup
use: browser:search-price@1.2.0
with: { query: ${{ input.query }} }
- id: shape
use: transform
with:
expr: { price: ${{ steps.lookup.output }}, at: ${{ now }} }
- id: notify
use: http.post
when: ${{ steps.shape.output.price < input.threshold }}
with:
url: ${{ secret.webhook }}
json: ${{ steps.shape.output }}
output: { price: ${{ steps.shape.output.price }} }
式言語
式は ${{ … }} の中に置かれ、小さく、純粋で、副作用のないサブ言語です。意図的に汎用プログラミング言語ではありません(原則: 言語ではない)。文法を要約すると次のとおりです。
- 参照 —
input.<name>、steps.<id>.output[.path]、env.<KEY>、secret.<KEY>、now、run.id。
- 演算子 —
+ - * / %、== != < <= > >=、&& || !、三項演算子 a ? b : c、??(nullish)、[]/. メンバーアクセス。
- 組み込み関数 — 固定された監査済みの集合:
len、lower、upper、trim、json、default(x, y)、map、filter、contains。すべての関数は純粋です。時計、ランダム性、I/Oはありません。これにより、static checkerは式が非決定性を隠していないことを証明できます。非決定的な値は式ではなく、記録されたソースから来ます。時刻には now 参照(注入された時計)を使い、新しいidには std/control の uuid のようなTool(出力がjournalされ、リプレイされるもの)を使います。ユーザー定義関数はなく、ループもありません(手順レベルの foreach を使います)。
- リテラル — string、number、boolean、null、array、object。
すべての式はコンパイル時にASTへパースされ、参照先スキーマに対して型チェックされます。未宣言の参照や許可集合外の関数に触れている場合は拒否されます。これが再現性を誠実に保ちます。式の中に時計の読み取りやネットワーク呼び出しを隠す場所はありません。
YAMLでの制御フロー
| 構文 | YAML | 意味 |
|---|
| 条件 | 手順上の when: ${{ … }} | truthyの場合だけ手順を実行する |
| branch | yes: / no: の手順リストを持つ branch: block | sub-listを選択する |
| fan-out | 手順上の foreach: ${{ … }} | 配列要素ごとに手順を実行し、出力を集める |
| parallel | (暗黙) | data edgeのない手順は並行に実行される |
| nesting | use: flow:checkout@2 | 別のFlowを1つの手順(composite tool)として呼び出す |
3. 同じFlow、3通り
上記のSDKと上記のYAMLは、同一の正規FlowSpecへコンパイルされます。
export default flow("daily-price-watch")
.input("query", z.string())
.input("threshold", z.number())
.input("webhook", z.string().url(), { secret: true })
.step("lookup", browser.tool("search-price@1.2.0").with((c) => ({ query: c.input.query })))
.step("shape", transform((c) => ({ price: c.steps.lookup.output, at: c.now })))
.branch("dropped?", (c) => c.steps.shape.output.price < c.input.threshold, {
yes: (f) =>
f.step("notify", http.post((c) => c.secret.webhook, (c) => c.steps.shape.output)),
no: (f) => f.noop("unchanged"),
})
.output((c) => ({ price: c.steps.shape.output.price }));
flow: daily-price-watch
inputs:
- { name: query, type: string }
- { name: threshold, type: number }
- { name: webhook, type: string, secret: true }
steps:
- id: lookup
use: browser:search-price@1.2.0
with: { query: ${{ input.query }} }
- id: shape
use: transform
with:
expr: { price: ${{ steps.lookup.output }}, at: ${{ now }} }
- id: notify
use: http.post
when: ${{ steps.shape.output.price < input.threshold }}
with:
url: ${{ secret.webhook }}
json: ${{ steps.shape.output }}
output: { price: ${{ steps.shape.output.price }} }
どちらも同じ正規FlowSpecにlowerされます。
{
"schema_version": "1.0",
"name": "daily-price-watch",
"inputs": [
{ "name": "query", "schema": { "type": "string" }, "secret": false },
{ "name": "threshold", "schema": { "type": "number" }, "secret": false },
{ "name": "webhook", "schema": { "type": "string" }, "secret": true }
],
"steps": [
{ "id": "lookup", "use": "browser:search-price@1.2.0",
"with": { "query": "${{ input.query }}" } },
{ "id": "shape", "use": "transform",
"with": { "expr": "{ price: ${{ steps.lookup.output }}, at: ${{ now }} }" },
"needs": ["lookup"] },
{ "id": "notify", "use": "http.post",
"when": "${{ steps.shape.output.price < input.threshold }}",
"with": { "url": "${{ secret.webhook }}", "json": "${{ steps.shape.output }}" },
"needs": ["shape"] }
],
"output": "{ price: ${{ steps.shape.output.price }} }",
"requires": [
{ "ref": "search-price@1.2.0", "contract_digest": "sha256:b91c…" }
],
"digest": "sha256:7f3a…"
}
サーフェスが何をlowerしてくれたかに注目してください。needs は参照から推論されました。.branch() / when: sugarは notify 上の when(steps.shape.output.price < input.threshold)にlowerされ、no: noop("unchanged") のarmは作用がなく、下流からの参照もないため、dead noopとして取り除かれました(03 §6a)。YAMLの type: string 省略記法はJSON-Schema slot { "type": "string" } に展開され、output object literalは単一の式として運ばれます。そして requires の各entryはバージョンだけでなく、解決済みのcontract digestを持ちます。つまりFlow digestはToolの振る舞いを閉じ込めます(03 §5)。あなたはそのどれも書いていません。
4. ラウンドトリップ
両方のサーフェスが同じIRを対象にしているため、toolingは自由に変換できます。
browserflow compile flow.yaml # YAML → FlowSpec(+ digest)
browserflow emit flow.ts --to yaml # SDK → YAML
browserflow emit flow.flow.json --to yaml # FlowSpec → YAML
正規YAML emitterは決定論的(sorted keys、normalized expressions)なので、compile してから emit すると不動点になります。GitにチェックインされたFlowは安定したdiffを持ちます。SDK → YAMLのpathは、エンジニアがFlowを非エンジニアに渡してUIで保守してもらうための方法です。YAML → SDKのpath(browserflow emit … --to ts)は、YAMLで下書きされたFlowから型付きビルダーをscaffoldします。
どちらで書けばよいですか? コンパイル時の型が必要で、TSコードベースで作業しているならSDKを使います。FlowをTypeScriptを書かない人にレビュー、diff、編集してもらう必要があるならYAMLを使います。両者は意図的に交換可能です。
5. AIによるオーサリング — ただし境界部分だけ
原則T2と一貫して、AIの支援は作成時の利便性であり、ランタイム依存ではありません。
- LLMはdraft YAML Flowの作成を手伝えます。その後、あなたがレビューし、
browserflow compile --check でコンパイルして、pinします。
browserflow record --ops <file> は、準備済みのops fileから個別のbrowser toolを作成します。その結果は、runtimeではmodelなしにリプレイされる決定論的なRecordingです(05を参照)。
コンパイルされ、pinされると、Flowはdigestを持ち、loop内にmodelを含めずに実行されます。AIはあなたがそれを書くのを助けただけで、実行方法には一切関与しません。
FlowSpec IR
これらのサーフェスが対象にする正規形式。
Tools & Packs
browser、http、transformのToolがどのように定義、記録、複製されるか。