Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This repository publishes Agent Skills for Tines products. Keep every skill port

## Skill structure

Place each skill at `skills/<skill-name>/SKILL.md`. Optional supporting files belong in `scripts/`, `references/`, or `assets/` within that skill’s directory.
Place each skill at `skills/<skill-name>/SKILL.md`. Optional agent-readable supporting files belong in `scripts/`, `references/`, or `assets/` within that skill’s directory. Product-specific client metadata belongs in `agents/`; keep it optional so clients that do not support it can still use the skill.

Follow the [Agent Skills specification](https://agentskills.io/specification):

Expand All @@ -14,6 +14,7 @@ Follow the [Agent Skills specification](https://agentskills.io/specification):
- Set `compatibility` to the intended Tines product when a skill is product-specific (for example, `Tines 3B`).
- Keep the body focused on instructions an agent needs after the skill triggers.
- Link supporting files directly from `SKILL.md` so an agent can load them only when needed.
- Put OpenAI-specific interface metadata in `agents/openai.yaml`, not in `SKILL.md`.
- Preserve copyright and modification notices in derivative skills.

Run `gh skill publish --dry-run` before submitting a change.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Skills can target different Tines products. Check each skill’s `compatibility`

| Skill | Purpose |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [`building-workflows`](skills/building-workflows/) | Build and modify complete 3B workflows through Git, MCP, the CLI, or the in-product editor. |
| [`building-agents`](skills/building-agents/) | Build model calls, tool-using agents, conversations, and chat UIs into 3B workflows. |
| [`mcp-builder`](skills/mcp-builder/) | Build MCP servers, including servers deployed as authenticated 3B workflow routes. |
| [`mcp-client`](skills/mcp-client/) | Call remote MCP servers over streamable HTTP from a 3B workflow. |
Expand Down Expand Up @@ -67,7 +68,7 @@ Skills can target different Tines products. Check each skill’s `compatibility`

## Structure

Each directory under `skills/` is a self-contained skill with a required `SKILL.md` and any supporting scripts, references, or assets it needs.
Each directory under `skills/` is a self-contained skill with a required `SKILL.md` and any agent-readable scripts, references, or assets it needs. Optional product-specific client metadata lives under `agents/`, such as OpenAI interface metadata in `agents/openai.yaml`; clients that do not support it can ignore it.

## License

Expand Down
1 change: 1 addition & 0 deletions skills.sh.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"title": "Build workflows",
"description": "Skills for designing and building composable workflows with Tines products.",
"skills": [
"building-workflows",
"building-agents",
"mcp-builder",
"mcp-client",
Expand Down
20 changes: 20 additions & 0 deletions skills/building-workflows/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
name: building-workflows
description: Build and modify Tines 3B workflows from a workflow or space Git checkout, the 3B MCP server, the 3B CLI, or the in-product editor. Use whenever an agent works with workflow.toml, step directories, config.toml, FROM 3b/base Dockerfiles, workflow routes, links, triggers, connectors, step tests, or needs to create, inspect, run, debug, commit, or publish a 3B workflow.
license: Apache-2.0
compatibility: Tines 3B
---

# Build 3B workflows

Read [references/workflow-format.md](references/workflow-format.md) before changing workflow files. Read [references/interfaces.md](references/interfaces.md) when choosing or using an editing interface. Read [references/testing.md](references/testing.md) before creating, updating, or running step tests.

3B workflows have a few nonstandard contracts:

- Each step is an isolated process. Links pipe an upstream step’s stdout to downstream stdin; zero-byte stdout suppresses downstream execution.
- A step reports failure only by exiting nonzero. Write workflow data to stdout and diagnostics to stderr.
- Create steps from 3B’s templates and preserve their `FROM 3b/base` Dockerfiles.
- Attach connectors with connector tooling; never edit `connectors` in `config.toml` by hand.
- Never put credentials or authorization headers in workflow code. A connector’s proxy injects authentication at runtime.
- Routes default to space-private. Use `route_auth = "public"` only when the user explicitly requests unauthenticated internet access.
- Run affected steps and existing tests with representative input before committing or publishing.
4 changes: 4 additions & 0 deletions skills/building-workflows/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Build 3B workflows"
short_description: "Build and modify Tines 3B workflows"
default_prompt: "Use $building-workflows to build or modify this Tines 3B workflow."
25 changes: 25 additions & 0 deletions skills/building-workflows/assets/templates/agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM 3b/base

RUN \
--mount=type=cache,target=bun \
case $(uname -m) in \
x86_64) ARCH=linux-x64; SHA=3632a4fae998deaad2358e22ae74d1fd6c673b9be760a470e3a0044267cb4438;; \
*) ARCH=linux-aarch64; SHA=1edcc88fd13c16471aa29ec9d5af4063e43ec9b71d34e89cb7e43dbec718aaf0;; \
esac && \
curl --proto '=https' --tlsv1.2 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v1.3.10/bun-${ARCH}.zip" -o /tmp/bun.zip && \
unzip -qoj /tmp/bun.zip -d . "bun-${ARCH}/bun" && \
printf '%s bun\n' "$SHA" | sha256sum -c - && \
chmod +x ./bun && \
./bun --version >/dev/null

COPY package.json .
RUN \
--mount=type=cache,target=node_modules \
if [ -f package.json ]; then ./bun install; fi && \
mkdir -p node_modules

# The agent owns one transcript file per conversation under /storage/conversations,
# keyed by the authenticated end-user and conversation id (see runtime/transcript.ts).
VOLUME ["conversations"]

CMD ./bun ./agent.ts
89 changes: 89 additions & 0 deletions skills/building-workflows/assets/templates/agent/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// The agent's entry point. An agent is its four config files — system.md
// (instructions), tools/ (one tool per file), model.json (provider, model,
// effort, limits), config.toml (connectors, and a `route` to be caller-invoked)
// — plus buildInput below, which you own. The runtime/ directory is ordinary
// code too, for when those aren't enough.
import { runAgent } from "./runtime/loop";
import {
authenticatedPrincipal,
isHttpRequest,
requestBodyOrRaw,
} from "./runtime/request";
import { parsePriorRun } from "./runtime/selfLoop";
import { startResponse } from "./runtime/response";
import { transcriptPath } from "./runtime/transcript";

// Shape this workflow's trigger into the agent's input. The template can't know
// what the trigger emits, so replace the body of this function when you build
// the workflow. `body` is the raw trigger payload: the upstream step's stdout,
// or a routed agent's request body (the HTTP framing and auth headers are
// already stripped). Return the message the agent acts on, an optional context
// handed to every tool's execute as `options.experimental_context`, and — for a
// chat route whose callers continue threads — the conversationId to resume
// (omit it to start a fresh thread).
function buildInput(body: string): {
message: string;
context?: unknown;
conversationId?: string;
} {
return { message: body };
}

// Everything below is the template's mechanics, not this workflow's shape.

// Reduce a caller-supplied conversation id to a safe filename; mint a UUID when
// there is none.
function safeConversationId(id: string | undefined): string {
const cleaned = id ? id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) : "";
return cleaned || crypto.randomUUID();
}

const raw = await Bun.stdin.text();
// A run arriving as an HTTP request came through this step's own route, so a
// caller is watching the stream; anything else — an upstream step's output or a
// self-loop continuation — is async, and the loop makes exactly one model
// request per run and writes a single structured result instead of the stream.
const interactive = isHttpRequest(raw);
// A self-looped step receives its own previous run's structured result. Unless
// it reports the task still running, exit before writing anything: an empty
// stdout triggers no downstream step, which ends the loop. The HTTP guard
// doubles as a security check: a routed caller must never be able to forge a
// prior run.
const priorRun = interactive ? null : parsePriorRun(raw);
if (priorRun && priorRun.status !== "running") {
console.error(
`self-loop ended: previous run ${priorRun.status}${priorRun.error ? ` (${priorRun.error})` : ""}`
);
process.exit(0);
}

const out = startResponse(interactive);
// A continuation run sends no new user message — the transcript already ends
// with the pending tool results, and the model resumes from there.
let message: string | null = null;
let context: unknown;
let conversationId: string;
if (priorRun) {
conversationId = priorRun.conversationId;
} else {
const input = buildInput(requestBodyOrRaw(raw));
message = input.message;
context = input.context;
conversationId = safeConversationId(input.conversationId);
}
// Scope the transcript to the authenticated caller so each user's threads are
// isolated; an unauthenticated route or a headless run has no verified caller, so
// it shares one namespace keyed only by the (unguessable) conversation id. A
// continuation run inherits the owner from the run that started the thread.
const owner = priorRun
? (priorRun.owner ?? "shared")
: (authenticatedPrincipal(raw) ?? "shared");
out.event({ type: "conversation", conversationId, owner });
await runAgent({
message,
context,
transcriptFile: transcriptPath(owner, conversationId),
out,
interactive,
});
process.exit(0);
5 changes: 5 additions & 0 deletions skills/building-workflows/assets/templates/agent/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
color = "purple"
timeout = 300
# Add a route (e.g. route = "chat") plus output = true to let callers invoke the
# agent directly and receive the event stream as the HTTP response; without a
# route it is headless — the upstream step's output is its input.
11 changes: 11 additions & 0 deletions skills/building-workflows/assets/templates/agent/model.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"provider": "anthropic",
"model": "claude-opus-5",
"effort": "high",
"thinking": { "type": "adaptive", "display": "summarized" },
"limits": {
"maxSteps": 100,
"tokenBudget": 5000000,
"maxOutputTokens": 64000
}
}
9 changes: 9 additions & 0 deletions skills/building-workflows/assets/templates/agent/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "module",
"dependencies": {
"@ai-sdk/anthropic": "3.0.81",
"@ai-sdk/provider": "3.0.8",
"ai": "6.0.145",
"zod": "4.3.6"
}
}
78 changes: 78 additions & 0 deletions skills/building-workflows/assets/templates/agent/runtime/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { z } from "zod";

// Used when model.json sets no limits. maxSteps caps model requests across
// every run of a conversation, not one step run.
const DEFAULT_MAX_STEPS = 100;
const DEFAULT_TOKEN_BUDGET = 5_000_000;
const DEFAULT_MAX_OUTPUT_TOKENS = 64_000;

// Mirrors the Anthropic provider's `thinking` option. Omit it to take the
// default (adaptive thinking with a summarized reasoning stream); set
// `{ "type": "disabled" }` to turn thinking off for a cheap routing agent.
const ThinkingSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("adaptive"),
display: z.enum(["omitted", "summarized"]).optional(),
}),
z.object({
type: z.literal("enabled"),
budgetTokens: z.number().int().positive().optional(),
}),
z.object({ type: z.literal("disabled") }),
]);

export type ThinkingConfig = z.infer<typeof ThinkingSchema>;

const AgentConfigSchema = z.object({
provider: z.string(),
model: z.string(),
effort: z.string().optional(),
baseUrl: z.string().optional(),
thinking: ThinkingSchema.optional(),
limits: z
.object({
maxSteps: z.number().int().positive().optional(),
tokenBudget: z.number().int().positive().optional(),
maxOutputTokens: z.number().int().positive().optional(),
})
.optional(),
});

export type ModelConfig = {
provider: string;
model: string;
effort?: string;
baseUrl?: string;
thinking?: ThinkingConfig;
};

export type Limits = {
maxSteps: number;
tokenBudget: number;
maxOutputTokens: number;
};

export type AgentConfig = { model: ModelConfig; limits: Limits };

export function parseAgentConfig(text: string): AgentConfig {
const raw = AgentConfigSchema.parse(JSON.parse(text));
const limits = raw.limits ?? {};
return {
model: {
provider: raw.provider,
model: raw.model,
effort: raw.effort,
baseUrl: raw.baseUrl,
thinking: raw.thinking,
},
limits: {
maxSteps: limits.maxSteps ?? DEFAULT_MAX_STEPS,
tokenBudget: limits.tokenBudget ?? DEFAULT_TOKEN_BUDGET,
maxOutputTokens: limits.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
},
};
}

export async function readAgentConfig(): Promise<AgentConfig> {
return parseAgentConfig(await Bun.file("model.json").text());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { APICallError, RetryError } from "ai";

export function isPromptTooLongError(error: unknown): boolean {
// Non-retryable errors normally arrive bare, but the `ai` SDK wraps one in a
// RetryError if an earlier attempt failed for a retryable reason.
const cause = RetryError.isInstance(error) ? error.lastError : error;
if (APICallError.isInstance(cause)) {
return (
cause.statusCode === 413 ||
cause.message.includes("prompt is too long") ||
cause.message.includes("exceeds the context window")
);
}
return false;
}
38 changes: 38 additions & 0 deletions skills/building-workflows/assets/templates/agent/runtime/effort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Order is load-bearing: the low→high ramp, not just a validation set.
export const effortLevels = ["low", "medium", "high", "max"] as const;

export type EffortLevel = (typeof effortLevels)[number];

export function isEffortLevel(value: unknown): value is EffortLevel {
return (
typeof value === "string" &&
(effortLevels as readonly string[]).includes(value)
);
}

// Per-model effort capability: an ordered rule list matched against the model id;
// the first matching rule's `levels` win. A model that matches no rule (or a
// provider with no rules) has no reasoning-effort knob.
export type ModelEffortRule = {
pattern: RegExp;
levels: readonly EffortLevel[];
};

export function availableEffortLevels(
rules: readonly ModelEffortRule[],
model: string
): readonly EffortLevel[] {
return rules.find((rule) => rule.pattern.test(model))?.levels ?? [];
}

// Clamp a requested level to what the model offers, or null when the model has
// no reasoning-effort knob.
export function clampEffort(
rules: readonly ModelEffortRule[],
model: string,
effort: EffortLevel
): EffortLevel | null {
const available = availableEffortLevels(rules, model);
if (available.includes(effort)) return effort;
return available.at(-1) ?? null;
}
51 changes: 51 additions & 0 deletions skills/building-workflows/assets/templates/agent/runtime/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { UsageTotals } from "./transcript";

// The wire contract for an agent run's output: one NDJSON event per line on
// stdout. The first event is always `conversation`, carrying the id this thread
// is keyed by (echoed back, or minted when the caller sends none).
// `owner` lets a self-looped continuation run reopen the same transcript when
// the first run was routed and authenticated.
export type AgentEvent =
| { type: "conversation"; conversationId: string; owner?: string }
| { type: "user-message"; text: string }
| { type: "text-delta"; text: string }
| { type: "reasoning-delta"; text: string }
| { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
| {
type: "tool-result";
toolCallId: string;
toolName: string;
output: unknown;
}
| { type: "tool-error"; toolCallId: string; toolName: string; error: string }
| { type: "usage"; turn: number; usage: UsageTotals; totals: UsageTotals }
// `text` is the final answer, so a caller can read the result off this one
// line instead of reassembling deltas; empty if the run ended on a tool call
// or error. `finishReason` is the model's stop reason for the run's last
// request — "tool-calls" means the task is still in flight.
| {
type: "done";
totals: UsageTotals;
text: string;
finishReason?: string;
turn: number;
}
| { type: "error"; error: string; contextLimit?: true };

// A headless run's entire stdout: one structured object instead of the event
// stream, which downstream steps don't need — the full trail is in the
// transcript. `status` is the loop state: "running" — the run ended on tool
// calls and the self-loop should trigger another run; "done" — the answer is in
// `text`; "error" — the run failed, was cut off, or spent its maxSteps budget
// (details on `error`). `turn` counts model requests across the whole
// conversation — for a self-looped agent, the iteration number.
export type AgentRunResult = {
type: "agent-run";
status: "running" | "done" | "error";
conversationId: string;
owner?: string;
turn: number;
text: string;
error?: string;
totals: UsageTotals;
};
Loading