@fabianferno/portalflow-sdk
v0.1.0
Published
Typed REST client + run helpers + workflow-as-code builder for PortalFlow (visual on-chain automation for Portaldot).
Maintainers
Readme
@portalflow/sdk
Typed REST client + run helpers + a workflow-as-code builder for PortalFlow — visual on-chain automation for Portaldot (Substrate; token POT, SS58 prefix 42, 14 decimals).
- Typed REST client — every PortalFlow API route, typed, with a configurable base URL and pluggable auth.
- Run helpers —
run()/test()/runAndWait()over the synchronous execution endpoints, with a typed step trace. - Workflow-as-code builder — assemble valid workflow graphs fluently. The
per-node methods and config/output types are generated from the live node
catalog (
GET /node-types), so the SDK stays in lockstep with the backend executors.
Node 18+ (uses global fetch). Browser-safe — no Node-only APIs on the client path.
npm install @portalflow/sdkQuick start
import { PortalFlowClient } from "@portalflow/sdk";
const pf = new PortalFlowClient({
baseUrl: "http://localhost:8000/api/v1", // default
token: process.env.PORTALFLOW_TOKEN, // JWT (Bearer) or the shared API_KEY
});
// Or log in (stores the token on the client):
await pf.auth.login("[email protected]", "password");
const { items } = await pf.workflows.list({ status: "Active", trigger: "manual" });
const result = await pf.workflows.run(items[0].id); // { run, steps }
for (const s of result.steps) {
console.log(s.node_type, s.status, s.fee, s.extrinsicHash);
}Auth
The SDK attaches whatever token you give it as Authorization: Bearer <token>.
That can be a JWT from auth.login / auth.signup, or the shared API_KEY
(which the backend maps to owner_id="default"). Full session/login management
is out of scope; pass a token you already have, or call login.
pf.setToken(jwt); // attach a token you already hold
const me = await pf.auth.me();REST client
// Workflows
await pf.workflows.list({ status: "Active", trigger: "manual", q: "faucet" });
await pf.workflows.get(id);
await pf.workflows.create({ name, desc, trigger, tags, graph });
await pf.workflows.update(id, { graph });
await pf.workflows.setStatus(id, "Active");
await pf.workflows.duplicate(id);
await pf.workflows.remove(id);
// Runs / catalog / wallet / templates
await pf.runs.list({ status: "Success", page: 1, perPage: 50 });
await pf.runs.get(runId);
await pf.nodeTypes(); // public, no auth — the executor-backed catalog
await pf.templates.list({ cat, q });
await pf.wallet.get();Any non-2xx response throws a PortalFlowError (status, code, message, path).
GET requests are retried on network/5xx errors with bounded backoff; non-idempotent
calls (/run, /wallet/send, …) are never retried.
Run helpers
/run (live — signs + submits) and /test (dry-run) are synchronous: they
block until the whole graph finishes and return { run, steps }.
const result = await pf.workflows.run(id); // live
const dry = await pf.workflows.test(id); // dry-run
const final = await pf.workflows.runAndWait(id, { refetch: true, timeoutMs: 60_000 });runAndWait returns the synchronous run result; with refetch: true it re-reads
GET /runs/{id} for the canonical persisted record. There is no server-side
streaming, so there is no live step-by-step stream in v1.
Workflow-as-code
import { workflow, addr, pot } from "@portalflow/sdk";
const wf = workflow("Auto top-up")
.trigger.manual()
.get_account({ account: addr("5Grw…") })
.condition({ left: "x", op: "greater than", right: pot(5) })
.build();
const created = await pf.workflows.create({
name: wf.name,
trigger: wf.trigger,
graph: wf.graph,
});- Chaining = an edge from the previous node to the new one.
.build()emits{ nodes, edges }with auto-assignedx/ylayout andidlestatus. - Branch ports:
condition/ai_gateexpose.true/.false;switchexposes.case(i). Selecting a branch routes the next edge through that port. - Titles & ids are generated and deduped (
GetAccountInfo,GetAccountInfo2).
Branches and typed refs
A node method returns a handle — keep chaining, or capture it to branch and
to build typed {{Title.output.field}} references:
const wf = workflow("balance guard");
wf.trigger.manual();
const acct = wf.get_account({ account: addr("5Grw…") });
const cond = wf.condition({ left: acct.ref("free_balance"), op: "less than", right: pot(5) });
cond.true.pot_transfer({ to: addr("5abc…"), amount: pot(5) });
cond.false.stop({ message: "balance ok" });
const built = wf.build();
// acct.ref("free_balance") -> "{{GetAccountInfo.output.free_balance}}"ref(field) is constrained to the node's generated output union, so a typo or a
renamed output is a compile error.
Validation
.build() throws PortalFlowBuildError when the graph is invalid: no/multiple
trigger nodes, dangling edges, a branch port on a non-branch node, or a missing
required field (the offending node id/field is on the error).
Round-trip
const existing = await pf.workflows.get(id);
const wf = WorkflowBuilder.fromGraph(existing.name, existing.graph)
.telegram({ token: "…", chatId: "…", text: "appended step" })
.build();
await pf.workflows.update(id, { graph: wf.graph });Known limitations
- Triggering is manual-only on the backend.
/runis the only operational trigger.schedule/webhook/ on-chain trigger nodes exist in the catalog but have no backend listener — the SDK can create such workflows but fires them by calling/run; automatic firing is separate backend work. - Assets/staking writes degrade to
Skippedon the dev node's V13 metadata (BadProof). The builder will happily emit those nodes, but runs reportSkippedfor them — typed success ≠ on-chain success for those types. - The bundled catalog is a snapshot (
CATALOG_VERSION). The package ships a drift-guard test; compare against the live catalog withpf.nodeTypes()and regenerate (npm run codegen) when the backend catalog changes.
License
MIT
