← All examples
PlatformWorkflow callsAI

Build a multi-workflow document processing API

Expose one product endpoint, route each job to a specialized durable workflow, return a run ID immediately, and let every processor evolve independently.

This pattern is the beginning of a specialized automation product. The router owns admission and product vocabulary. Child workflows own invoice extraction, contract review, or document summarization as separate run histories.

The platform at a glance

Text
POST /webhooks/documents
  → Validate tenant and job type
  → invoice  → start invoice-processor ───────┐
  → contract → start contract-review ────────┼→ Return child run ID
  → summary  → start document-summarizer ────┘

The public router

Save as document-platform.woml:

WOML
<woml>
  <workflow
    id="document-platform"
    name="Document processing platform"
    description="Route tenant documents to specialized durable processors."
    version="1.0.0"
  >
    <config concurrency="100" rate-limit="1000/1m" timeout="1m" queue="document-ingress" />

    <triggers>
      <webhook
        id="submitDocument"
        path="/webhooks/documents"
        method="POST"
        auth="bearer"
        secret="{{secrets.DOCUMENT_API_TOKEN}}"
      >
        <schema>
          {
            "type": "object",
            "required": ["tenantId", "jobId", "type", "text"],
            "properties": {
              "tenantId": { "type": "string" },
              "jobId": { "type": "string" },
              "type": { "type": "string", "enum": ["invoice", "contract", "summary"] },
              "text": { "type": "string", "minLength": 1 }
            },
            "additionalProperties": false
          }
        </schema>
      </webhook>
    </triggers>

    <steps>
      <step id="job" name="Normalize platform job">
        <script>
          return {
            tenantId: context.payload.tenantId,
            jobId: context.payload.jobId,
            type: context.payload.type,
            text: context.payload.text
          };
        </script>
      </step>

      <switch id="route" name="Select document processor" value="{{context.steps.job.type}}">
        <case value="invoice">
          <step id="startInvoiceProcessor">
            <script>
              return services.workflows.start(
                "invoice-processor",
                context.steps.job,
                { name: "start-invoice-processor" }
              );
            </script>
          </step>
          <result value="{{context.steps.startInvoiceProcessor}}" />
        </case>

        <case value="contract">
          <step id="startContractReview">
            <script>
              return services.workflows.start(
                "contract-review",
                context.steps.job,
                { name: "start-contract-review" }
              );
            </script>
          </step>
          <result value="{{context.steps.startContractReview}}" />
        </case>

        <case value="summary">
          <step id="startDocumentSummarizer">
            <script>
              return services.workflows.start(
                "document-summarizer",
                context.steps.job,
                { name: "start-document-summarizer" }
              );
            </script>
          </step>
          <result value="{{context.steps.startDocumentSummarizer}}" />
        </case>

        <default>
          <step id="unsupportedDocument"><script>return { error: "unsupported-document-type" };</script></step>
          <result value="{{context.steps.unsupportedDocument}}" />
        </default>
      </switch>

      <step id="response" name="Return accepted job">
        <script>
          return {
            jobId: context.steps.job.jobId,
            processor: context.steps.job.type,
            workflowId: context.steps.route.workflowId,
            runId: context.steps.route.runId,
            duplicate: context.steps.route.duplicate
          };
        </script>
      </step>
    </steps>
  </workflow>
</woml>

A reusable AI module

Save as document-ai.ts:

TypeScript
export async function analyze(
  instruction: string,
  text: string,
  apiKey: string,
) {
  const response = await services.http.request({
    method: "POST",
    url: "https://api.openai.com/v1/chat/completions",
    headers: { authorization: `Bearer ${apiKey}` },
    json: {
      model: "gpt-4.1-mini",
      messages: [{ role: "user", content: `${instruction}\n\n${text}` }]
    },
    timeout: "45s"
  }, { name: "analyze-platform-document" });

  return response.data.choices[0].message.content;
}

The invoice processor

Save as invoice-processor.woml:

WOML
<woml>
  <imports><module name="documentAi" from="./document-ai.ts" /></imports>
  <workflow id="invoice-processor" name="Invoice processor" version="1.0.0">
    <config concurrency="20" timeout="5m" queue="invoice-jobs" />
    <steps>
      <step id="extractInvoice" retry="3">
        <script>
          const extraction = await services.documentAi.analyze(
            "Extract supplier, invoice number, date, currency, and total as JSON.",
            context.payload.text,
            secrets.OPENAI_API_KEY
          );
          return { extraction: JSON.parse(extraction) };
        </script>
      </step>
      <step id="storeInvoice" retry="3">
        <script>
          const db = services.db({ driver: "postgres", connection: secrets.POSTGRES_URL });
          await db.insert({
            table: "processed_documents",
            values: {
              tenant_id: context.payload.tenantId,
              job_id: context.payload.jobId,
              type: "invoice",
              result_json: JSON.stringify(context.steps.extractInvoice.extraction)
            }
          }, { name: "store-extracted-invoice" });
          return { jobId: context.payload.jobId, result: context.steps.extractInvoice.extraction };
        </script>
      </step>
    </steps>
  </workflow>
</woml>

The contract and summary processors

Save these as contract-review.woml and document-summarizer.woml:

WOML
<woml>
  <imports><module name="documentAi" from="./document-ai.ts" /></imports>
  <workflow id="contract-review" name="Contract review" version="1.0.0">
    <config concurrency="10" timeout="24h" queue="contract-jobs" />
    <triggers><event id="contractReviewRequested" name="contract.review.requested" /></triggers>
    <steps>
      <step id="analyzeContract" retry="3"><script>
        const findings = await services.documentAi.analyze(
          "Identify obligations, renewal terms, liability, and unusual risk. Return concise JSON.",
          context.payload.text,
          secrets.OPENAI_API_KEY
        );
        return { findings: JSON.parse(findings) };
      </script></step>
      <approval id="legalReview" name="Review contract findings" timeout="8h" on-timeout="reject">
        <notify><slack channels="#legal-review" bot-token="{{secrets.SLACK_BOT_TOKEN}}" app-token="{{secrets.SLACK_APP_TOKEN}}" /></notify>
        <when-approved><step id="acceptFindings"><script>return { accepted: true, findings: context.steps.analyzeContract.findings };</script></step></when-approved>
        <when-rejected><step id="rejectFindings"><script>return { accepted: false, findings: context.steps.analyzeContract.findings };</script></step></when-rejected>
      </approval>
    </steps>
  </workflow>
</woml>
WOML
<woml>
  <imports><module name="documentAi" from="./document-ai.ts" /></imports>
  <workflow id="document-summarizer" name="Document summarizer" version="1.0.0">
    <config concurrency="40" timeout="5m" queue="summary-jobs" />
    <steps>
      <step id="summarize" retry="3"><script>
        const summary = await services.documentAi.analyze(
          "Summarize this document for an executive in five bullet points.",
          context.payload.text,
          secrets.OPENAI_API_KEY
        );
        return { jobId: context.payload.jobId, summary };
      </script></step>
    </steps>
  </workflow>
</woml>

Run the platform

Terminal
woml secrets set DOCUMENT_API_TOKEN
woml secrets set OPENAI_API_KEY
woml secrets set POSTGRES_URL
woml secrets set SLACK_BOT_TOKEN
woml secrets set SLACK_APP_TOKEN
woml check document-platform.woml invoice-processor.woml contract-review.woml document-summarizer.woml
woml run document-platform.woml invoice-processor.woml contract-review.woml document-summarizer.woml

The public request returns the child run ID after durable admission. Use that ID in your product to show status or follow logs while the specialized workflow continues independently.

Why this shows WOML's range

This is not one automation—it is a small workflow-backed product. The public API, tenant payload, routing, child ownership, AI implementation, SQL records, legal approval, policies, history, and operational identities are all explicit. Each processor can evolve without turning the router into a giant canvas.