Skip to content

Node Contract

Every node in a tentacle is a TypeScript file with a single default export.

import type { Context } from "tentacular";
export default async function run(ctx: Context, input: unknown): Promise<unknown> {
// Node implementation
}
  • ctx — The Context object providing dependency access, logging, config, and secrets
  • input — Output from upstream nodes. Single dependency: passed directly. Multiple dependencies: merged into keyed object.
  • Return value — Passed as input to downstream nodes
MemberTypeDescription
ctx.dependency(name)(string) => DependencyConnectionPrimary API. Returns connection metadata and resolved secret for a declared contract dependency. HTTPS deps include fetch(path, init?) URL builder.
ctx.logLoggerStructured logging (info, warn, error, debug) prefixed with [nodeId]
ctx.configRecord<string, unknown>Workflow-level config from config: in workflow.yaml
ctx.fetch(service, path, init?)Promise<Response>Legacy. Flagged as contract violation when contract is present. Use ctx.dependency() instead.
ctx.secretsRecord<string, Record<string, string>>Legacy. Flagged as contract violation when contract is present. Use ctx.dependency().secret instead.

The object returned by ctx.dependency(name):

FieldTypeDescription
protocolstringProtocol from the contract (e.g., https, postgres)
hoststringResolved hostname
portnumberResolved port
secretstringThe resolved secret value
authTypestringAuth type from contract (e.g., bearer-token, api-key)
fetch(path, init?)Promise<Response>URL builder for HTTPS deps. Does NOT inject auth headers.

dep.fetch() builds the URL but does not inject auth. Nodes handle auth explicitly using dep.secret and dep.authType:

const gh = ctx.dependency("github-api");
const resp = await gh.fetch!("/repos/owner/repo", {
headers: { "Authorization": `Bearer ${gh.secret}` },
});
const data = await resp.json();
export default async function run(ctx: Context, input: { items: string[] }) {
ctx.log.info(`Processing ${input.items.length} items`);
return {
processed: input.items.map(item => item.toUpperCase()),
count: input.items.length,
};
}
export default async function run(ctx: Context, input: unknown) {
const gh = ctx.dependency("github-api");
const resp = await gh.fetch!("/user/repos?per_page=100", {
headers: { "Authorization": `Bearer ${gh.secret}` },
});
if (!resp.ok) throw new Error(`GitHub API error: ${resp.status}`);
return { repos: await resp.json() };
}

When a node has multiple incoming edges, input is a keyed object:

export default async function run(ctx: Context, input: {
"code-scan": { issues: Issue[] };
"dep-review": { vulnerabilities: Vuln[] };
}) {
const issues = input["code-scan"].issues;
const vulns = input["dep-review"].vulnerabilities;
// Synthesize results from both upstream nodes
}
export default async function run(ctx: Context, input: unknown) {
const topN = (ctx.config.top_links_count as number) ?? 20;
const timeout = ctx.config.timeout as string;
// Use config values
}

Create a JSON fixture at tests/fixtures/<node-name>.json:

{
"input": { "query": "test" },
"expected": { "results": [] }
}

Optional fields for testing nodes that use config or secrets:

{
"input": { "alert": true },
"config": { "endpoints": ["https://example.com"] },
"secrets": { "slack": { "webhook_url": "https://hooks.slack.com/test" } },
"expected": { "delivered": false }
}
FieldTypeRequiredDescription
inputanyYesValue passed as input to the node function
configRecord<string, unknown>NoInjected as ctx.config
secretsRecord<string, Record<string, string>>NoInjected as ctx.secrets
expectedanyNoExpected return value (JSON deep equality)
Terminal window
tntc test # all node fixtures
tntc test my-tentacle/fetch-data # single node
tntc test --pipeline # full DAG end-to-end

The engine provides a mock context for testing (engine/testing/mocks.ts). Mock ctx.dependency() returns metadata with mock secret values and records access for drift detection. HTTPS mock deps return { mock: true, dependency, path } from fetch().

Nodes import types via:

import type { Context } from "tentacular";

This is resolved through the deno.json import map:

{
"imports": {
"tentacular": "./mod.ts",
"std/": "https://deno.land/std@0.224.0/"
}
}

At deploy time, jsr and deno.land/std URLs are rewritten through the in-cluster ESM module proxy for supply-chain security.