Skip to content

Workflow example ​

Schedule a meeting ​

This workflow turns a natural-language meeting request into a calendar event while keeping missing information and final approval with the user.

Schedule meeting workflow

What this flow does ​

  1. understand-request extracts the meeting details. If required information is missing, ask-for-missing-information collects it and returns to the same model turn.
  2. check-calendar calls the availability tool after the request is complete.
  3. find-suitable-times deterministically selects up to three slots that fit the requested duration and date range.
  4. prepare-proposal chooses a slot and formats the event details for review.
  5. user-approval pauses the workflow for explicit confirmation, and is-approved routes the result.
  6. Approved requests call create-event; declined requests skip creation. Both routes finish at return-response with a user-facing result.

The graph separates model judgment, deterministic code, external side effects, and human decisions into explicit nodes. Routed connections make the interaction loop and the approval boundary visible before the workflow runs.

Process a chat turn ​

The diagram below is based on a production-style chat workflow. It shows a common pattern: load state, run one or more model turns, optionally call tools in a loop, rewrite/finalize output, then persist updated state.

Process chat turn workflow

What this flow does ​

  1. load-history and load-soul load session transcript and style/personality guidance from memory.
  2. prepare-turn validates and normalizes user input (including attachments).
  3. draft-answer runs an agent-request turn and may route to tool.
  4. draft-answer-tool executes the selected tool call, then returns to draft-answer.
  5. rewrite-with-soul performs a final rewrite pass for tone and consistency.
  6. finalize-turn, save-history, and return-response produce output and persist conversation state.

What this looks like in workflow code ​

The graph is ordinary workflow YAML: node definitions plus explicit routed edges.

yaml
start: load-history

nodes:
  # Earlier state-loading and input-preparation nodes are omitted.
  - id: draft-answer
    type: agent-request
    maxSteps: 10
    prompt:
      template: >-
        Produce a draft answer. Call tools when needed.

  - id: draft-answer-tool
    type: tool-call
    input:
      tool:
        expr: nodes['draft-answer'].output.tool
      arguments:
        expr: nodes['draft-answer'].output.arguments
      callId:
        expr: nodes['draft-answer'].output.callId

  # Later rewriting and persistence nodes are omitted.

connections:
  # Connections from the omitted preparation nodes are omitted.
  - from: draft-answer
    route: tool
    to: draft-answer-tool
  - from: draft-answer-tool
    to: draft-answer
  - from: draft-answer
    route: final
    to: rewrite-with-soul
  # Connections to the omitted persistence nodes are omitted.

Tool access is declared in the workflow manifest, so model turns only see the tools you explicitly allow.

yaml
permissions:
  tools:
    - id: github-search
      tool: github/search_repositories
      consent: inherit
      required: true
    - id: fs-read
      tool: filesystem/read_file
      consent: inherit
      required: true

External provider implementation (what to build) ​

A provider is a trusted workspace driver under WORKSPACE_ROOT/drivers/provider/<type> with a manifest and TypeScript entrypoint.

manifest.yaml:

yaml
apiVersion: 1
kind: provider
type: openai-compatible
entrypoint: ./driver.ts

driver.ts (minimal skeleton):

ts
import type {
  AgentCapableModel,
  AgentMessage,
  AgentTurnRequest,
  AgentTurnResult,
  AiModelRequest,
  AiProviderDefinition,
} from "@faimulus/driver-sdk";

interface ProviderConfig {
  apiKey: string;
  model: string;
  baseUrl?: string;
}

interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string | null;
  tool_call_id?: string;
  tool_calls?: Array<{
    id: string;
    type: "function";
    function: { name: string; arguments: string };
  }>;
}

interface ChatResponse {
  choices?: Array<{ message?: ChatMessage }>;
}

class OpenAiCompatibleModel implements AgentCapableModel {
  constructor(private readonly config: ProviderConfig) {}

  private async complete(
    messages: ChatMessage[],
    tools?: AgentTurnRequest["tools"],
    signal?: AbortSignal,
  ): Promise<ChatMessage> {
    const response = await fetch(
      `${this.config.baseUrl ?? "https://api.openai.com/v1"}/chat/completions`,
      {
        method: "POST",
        signal,
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${this.config.apiKey}`,
        },
        body: JSON.stringify({
          model: this.config.model,
          messages,
          ...(tools?.length
            ? {
                tools: tools.map((tool) => ({
                  type: "function",
                  function: {
                    name: tool.name,
                    description: tool.description,
                    parameters: tool.inputSchema ?? { type: "object" },
                  },
                })),
              }
            : {}),
        }),
      },
    );

    if (!response.ok)
      throw new Error(`Provider call failed with status ${response.status}`);

    const body = (await response.json()) as ChatResponse;
    return body.choices?.[0]?.message ?? { role: "assistant", content: "" };
  }

  async execute(request: AiModelRequest): Promise<unknown> {
    const message = await this.complete([
      ...(request.prompt
        ? [{ role: "system" as const, content: request.prompt.template }]
        : []),
      { role: "user", content: JSON.stringify(request.input) },
    ]);
    return message.content ?? "";
  }

  async executeAgentTurn(request: AgentTurnRequest): Promise<AgentTurnResult> {
    const messages = request.messages.map(toChatMessage);
    const message = await this.complete(
      messages,
      request.tools,
      request.signal,
    );
    if (message.tool_calls?.length)
      return {
        type: "tool-calls",
        calls: message.tool_calls.map((call) => ({
          id: call.id,
          name: call.function.name,
          arguments: JSON.parse(call.function.arguments) as Record<
            string,
            unknown
          >,
        })),
      };
    return { type: "final", output: message.content ?? "" };
  }
}

function toChatMessage(message: AgentMessage): ChatMessage {
  if (message.role === "tool")
    return {
      role: "tool",
      content: message.content,
      tool_call_id: message.toolCallId,
    };
  if ("content" in message)
    return { role: message.role, content: message.content };
  return {
    role: "assistant",
    content: null,
    tool_calls: [
      {
        id: message.toolCall.id,
        type: "function",
        function: {
          name: message.toolCall.name,
          arguments: JSON.stringify(message.toolCall.arguments),
        },
      },
    ],
  };
}

const definition: AiProviderDefinition<ProviderConfig> = {
  type: "openai-compatible",
  displayName: "OpenAI compatible",
  configSchema: {
    type: "object",
    additionalProperties: false,
    required: ["apiKey", "model"],
    properties: {
      apiKey: { type: "string", writeOnly: true },
      model: { type: "string", minLength: 1 },
      baseUrl: { type: "string", format: "uri" },
    },
  },
  async createModel(config) {
    return new OpenAiCompatibleModel(config);
  },
};

export default definition;

Then wire it through workspace config:

yaml
# config/providers.yaml
version: 1
providers:
  main-openai:
    type: openai-compatible
    config:
      apiKey: ${OPENAI_API_KEY}
      baseUrl: https://api.openai.com/v1
yaml
# config/models.yaml
version: 1
models:
  general:
    provider: main-openai
    config:
      model: gpt-4.1-mini

Use different model roles per step ​

One workflow can use a cheaper fast model for routing/tool selection and a stronger model for final answer quality.

yaml
# config/models.yaml
version: 1
models:
  general:
    provider: main-openai
    config:
      model: gpt-4.1-mini
  router:
    provider: main-openai
    config:
      model: gpt-4.1-mini
  rewriter:
    provider: main-openai
    config:
      model: gpt-4.1
yaml
# workflow flow.yaml
- id: draft-answer
  type: agent-request
  model:
    role: general
  maxSteps: 10

- id: rewrite-with-soul
  type: agent-request
  model:
    role: rewriter
  maxSteps: 1

This keeps role names stable in workflow code while deployment can swap concrete models per environment.

Add real MCP servers ​

config/toolsets.yaml supports MCP Streamable HTTP records. In production, point these at real MCP services such as GitHub, Filesystem, and Postgres servers (typically behind an internal MCP gateway).

yaml
version: 1
toolsets:
  github:
    label: GitHub MCP
    enabled: true
    provider:
      type: mcp-streamable-http
      endpointUrl: http://mcp-gateway:8080/mcp/github
      authToken: ${GITHUB_MCP_TOKEN}
    policies:
      search_repositories: allow
      create_issue: ask

  filesystem:
    label: Filesystem MCP
    enabled: true
    provider:
      type: mcp-streamable-http
      endpointUrl: http://mcp-gateway:8080/mcp/filesystem
    policies:
      read_file: allow
      write_file: ask

  postgres:
    label: Postgres MCP
    enabled: true
    provider:
      type: mcp-streamable-http
      endpointUrl: http://mcp-gateway:8080/mcp/postgres
      authToken: ${POSTGRES_MCP_TOKEN}
    policies:
      query: allow
      execute: ask

Recommended operational pattern:

  1. Keep toolset credentials in environment variables referenced by authToken: ${ENV_VAR}.
  2. Start with conservative policies (ask for mutating operations).
  3. Declare only the specific tools each workflow needs in manifest.yaml permissions.
  4. Treat MCP servers as privileged infrastructure with network egress controls and audit logging.

Documentation for the current repository state.