Legal Contract Review Pipeline — End-to-End User Guide
A complete walkthrough for setting up, deploying, and running the Legal Contract Review multi-agent workflow.
Table of Contents
- Overview
- Pipeline Architecture
- Agent Reference
- Step 1 — Write the Agent Code
- Step 2 — Author the Workflow Spec
- Step 3 — Register the Workflow
- Step 4 — Deploy the Workflow Controller
- Step 5 — Execute a Task
- Understanding the Output
- Error Handling
- End-to-End Checklist
1. Overview
The Legal Contract Review Pipeline is a five-agent static workflow that takes raw contract text as input and produces a structured legal memo with risk findings, compliance analysis, redline suggestions, and a final recommendation.
What it does, end to end:
Raw contract text
↓
Extract clauses (clause-extractor-agent)
↓
Identify risks (risk-identifier-agent)
↓
Check compliance (compliance-checker-agent)
↓
Advise on redlines (negotiation-advisor-agent)
↓
Produce legal memo (legal-memo-agent)
Infrastructure used:
| Component | Address | Used for |
|---|---|---|
| Workflow Service | http://35.223.239.192:30721 |
Registering and deploying workflows |
| Agent Delegate API | http://35.223.239.192:30725 |
Running agent nodes (/api/submit-and-wait) |
| Policy DB | http://34.58.1.86:30102 |
local policy nodes (not used by this pipeline) |
| Workflow Executor | http://35.223.239.192:30712 |
Executing tasks. This address is the url returned by the deploy step, so yours will differ |
2. Pipeline Architecture
Execution Flow
The pipeline is static and linear — each agent runs sequentially and passes its full output to the next agent.
┌─────────────────────────┐
│ clause-extractor-agent │ ← receives: { "text": "<contract>" }
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ risk-identifier-agent │ ← receives: clauses{}
└────────────┬────────────┘
↓
┌──────────────────────────┐
│ compliance-checker-agent │ ← receives: clauses{} + risks[]
└────────────┬─────────────┘
↓
┌───────────────────────────┐
│ negotiation-advisor-agent │ ← receives: risks[] + compliance_findings[]
└────────────┬──────────────┘
↓
┌─────────────────────────┐
│ legal-memo-agent │ ← receives: full accumulated payload
└─────────────────────────┘
↓
Legal Memo Output
Accumulated Payload
Each agent carries forward all upstream fields plus its own additions. By the time legal-memo-agent runs, the full payload is:
| Field | Added By |
|---|---|
raw_contract |
clause-extractor-agent |
clauses |
clause-extractor-agent |
clause_count |
clause-extractor-agent |
risks |
risk-identifier-agent |
overall_risk_score |
risk-identifier-agent |
high_risk_count |
risk-identifier-agent |
compliance_findings |
compliance-checker-agent |
non_compliant_count |
compliance-checker-agent |
redlines |
negotiation-advisor-agent |
negotiation_stance |
negotiation-advisor-agent |
3. Agent Reference
| Agent | nodeID |
id |
Role |
|---|---|---|---|
| Clause Extractor | clause-extractor-agent |
w1-clause-1 |
Extracts and categorises contract clauses |
| Risk Identifier | risk-identifier-agent |
w1-risk-1 |
Scores each clause for risk (1–10) |
| Compliance Checker | compliance-checker-agent |
w1-comp-1 |
Checks clauses against GDPR, CCPA, Delaware law |
| Negotiation Advisor | negotiation-advisor-agent |
w1-nego-1 |
Produces redline suggestions |
| Legal Memo | legal-memo-agent |
w1-memo-1 |
Drafts the final legal memo |
All agent nodes set model_name: aios:qwen3-1-7b-vllm-block. The value reaches each agent as job_data.model_name; the sample agents below ignore it and call OpenAI through the llm.py helper.
Each id is the agent's subject ID. The Delegate API looks up the running agent by this ID, so each agent must be created as a subject and deployed (so it has a runtime subject) before the workflow runs.
4. Step 1 — Write the Agent Code
Each agent is a Python class implementing two methods: on_preprocess (input validation) and on_data (core logic), and is started with main(AgentClass) from the agents SDK (core.main). All agents follow the same structure.
Each agent's job_data is the output of the previous agent plus model_name (the first agent receives the workflow input).
4.0 LLM Helper
File: llm.py — shared by all five agents.
import json
import logging
import os
from openai import OpenAI, OpenAIError
log = logging.getLogger(__name__)
_client: OpenAI = None
def _get_client() -> OpenAI:
global _client
if _client is None:
_client = OpenAI() # reads OPENAI_API_KEY from the environment
return _client
def call_llm(system_prompt: str, user_message: str) -> str:
model = os.environ.get("OPENAI_MODEL", "gpt-5.4-2026-03-05")
client = _get_client()
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
temperature=0, # deterministic output for structured JSON
response_format={"type": "json_object"}, # enforce JSON mode
)
return response.choices[0].message.content or ""
def call_llm_json(system_prompt: str, user_message: str) -> dict:
"""Calls the LLM and parses the response as JSON.
Raises OpenAIError if the API call fails, and json.JSONDecodeError
(a ValueError) if the response is not valid JSON.
"""
raw_text = call_llm(system_prompt, user_message)
clean = raw_text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
return json.loads(clean)
Never hard-code the API key in
llm.py; setOPENAI_API_KEY(and optionallyOPENAI_MODEL) in the agent's environment.
4.1 Clause Extractor Agent
File: clause_extractor_agent.py
Input: { "text": "<raw contract string>" }
Output: { raw_contract, clauses{}, clause_count }
Guards: Skips if text is missing or fewer than 20 characters.
import logging
from typing import List, Optional
from openai import OpenAIError
from core.agent_executor import AgentResult, AgentTask, Context
from core.main import main
from llm import call_llm_json
log = logging.getLogger(__name__)
SYSTEM_PROMPT = """
You are a legal clause extraction specialist.
Given a contract text, extract and categorise every clause into the following categories:
- payment
- termination
- liability
- data_ownership
- sla
- renewal
- jurisdiction
Respond ONLY with a valid JSON object in this exact format, no preamble, no markdown:
{
"clauses": {
"payment": "<verbatim or summarised clause text, or null if not found>",
"termination": "<verbatim or summarised clause text, or null if not found>",
"liability": "<verbatim or summarised clause text, or null if not found>",
"data_ownership": "<verbatim or summarised clause text, or null if not found>",
"sla": "<verbatim or summarised clause text, or null if not found>",
"renewal": "<verbatim or summarised clause text, or null if not found>",
"jurisdiction": "<verbatim or summarised clause text, or null if not found>"
},
"clause_count": <number of non-null clauses found>
}
""".strip()
class ClauseExtractorAgent:
def __init__(self, subject, context: Context) -> None:
self.subject = subject
self.context = context
def on_preprocess(self, task: AgentTask) -> Optional[List[AgentTask]]:
text = task.job_data.get("text")
if not text or not isinstance(text, str):
log.warning("Task %s missing or invalid 'text' — skipping.", task.task_id)
return None
if len(text.strip()) < 20:
log.warning("Task %s 'text' too short (%d chars) — skipping.", task.task_id, len(text.strip()))
return None
return [task]
def get_muxer(self):
return None
def on_data(self, task: AgentTask) -> AgentResult:
try:
raw_contract = task.job_data["text"]
user_message = f"Extract and categorise all clauses from this contract:\n\n{raw_contract}"
try:
parsed = call_llm_json(SYSTEM_PROMPT, user_message)
except OpenAIError as e:
log.error("Task %s — OpenAI API error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"OpenAI error: {e}"})
except ValueError as e:
log.error("Task %s — JSON parse error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"JSON parse error: {e}"})
clauses = parsed.get("clauses", {})
clause_count = parsed.get("clause_count", sum(1 for v in clauses.values() if v))
log.info("Task %s — extracted %d clause(s)", task.task_id, clause_count)
return AgentResult(
task_id=task.task_id,
job_output={"raw_contract": raw_contract, "clauses": clauses, "clause_count": clause_count},
job_output_metadata={"clause_count": clause_count},
is_error=False,
)
except Exception as e:
log.exception("Task %s — unexpected error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": str(e)})
main(ClauseExtractorAgent)
4.2 Risk Identifier Agent
File: risk_identifier_agent.py
Input: { clauses{}, raw_contract }
Output: adds risks[], overall_risk_score (1–10), high_risk_count
Guards: Skips if clauses is missing or all values are null.
import json
import logging
from typing import List, Optional
from openai import OpenAIError
from core.agent_executor import AgentResult, AgentTask, Context
from core.main import main
from llm import call_llm_json
log = logging.getLogger(__name__)
SYSTEM_PROMPT = """
You are a senior legal risk analyst specialising in commercial contracts.
You will be given a set of extracted contract clauses. For each clause present,
identify any risks and assign:
- clause_type: the category of the clause
- severity: HIGH, MEDIUM, or LOW
- score: integer 1-10 (10 = most dangerous)
- finding: a concise one-line description of the risk
- reasoning: 2-3 sentences explaining why this is risky
Also compute an overall_risk_score (1-10) as a weighted average across all risks.
Respond ONLY with a valid JSON object, no preamble, no markdown:
{
"risks": [
{
"clause_type": "<category>",
"severity": "HIGH|MEDIUM|LOW",
"score": <1-10>,
"finding": "<finding>",
"reasoning": "<reasoning>"
}
],
"overall_risk_score": <1-10>,
"high_risk_count": <int>
}
""".strip()
class RiskIdentifierAgent:
def __init__(self, subject, context: Context) -> None:
self.subject = subject
self.context = context
def get_muxer(self):
return None
def on_preprocess(self, task: AgentTask) -> Optional[List[AgentTask]]:
clauses = task.job_data.get("clauses")
if not clauses or not isinstance(clauses, dict):
log.warning("Task %s missing or invalid 'clauses' — skipping.", task.task_id)
return None
if not {k: v for k, v in clauses.items() if v}:
log.warning("Task %s has no non-null clauses — skipping.", task.task_id)
return None
return [task]
def on_data(self, task: AgentTask) -> AgentResult:
try:
clauses = task.job_data["clauses"]
raw_contract = task.job_data.get("raw_contract", "")
user_message = "Identify risks in the following contract clauses:\n\n" + json.dumps(clauses, indent=2)
try:
parsed = call_llm_json(SYSTEM_PROMPT, user_message)
except OpenAIError as e:
log.error("Task %s — OpenAI API error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"OpenAI error: {e}"})
except ValueError as e:
log.error("Task %s — JSON parse error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"JSON parse error: {e}"})
risks = parsed.get("risks", [])
overall_score = parsed.get("overall_risk_score", 0)
high_risk_count = parsed.get("high_risk_count", sum(1 for r in risks if r.get("severity") == "HIGH"))
log.info("Task %s — %d risk(s), score=%s, high=%d", task.task_id, len(risks), overall_score, high_risk_count)
return AgentResult(
task_id=task.task_id,
job_output={"raw_contract": raw_contract, "clauses": clauses,
"risks": risks, "overall_risk_score": overall_score, "high_risk_count": high_risk_count},
job_output_metadata={"risk_count": len(risks), "overall_risk_score": overall_score,
"high_risk_count": high_risk_count},
is_error=False,
)
except Exception as e:
log.exception("Task %s — unexpected error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": str(e)})
main(RiskIdentifierAgent)
4.3 Compliance Checker Agent
File: compliance_checker_agent.py
Input: { risks[], clauses{}, raw_contract, overall_risk_score, high_risk_count }
Output: adds compliance_findings[], non_compliant_count
Guards: Skips if risks is missing or not a list.
import json
import logging
from typing import List, Optional
from openai import OpenAIError
from core.agent_executor import AgentResult, AgentTask, Context
from core.main import main
from llm import call_llm_json
log = logging.getLogger(__name__)
SYSTEM_PROMPT = """
You are a regulatory compliance attorney specialising in GDPR, CCPA, and Delaware contract law.
You will receive a list of identified contract risks. For each risk, determine whether
the associated clause is compliant with applicable regulations.
For each finding provide:
- clause_type: the clause category
- regulation: the specific regulation or legal standard (e.g. "GDPR Article 28", "CCPA Section 1798.100")
- status: one of NON_COMPLIANT, AT_RISK, or COMPLIANT
- detail: 2-3 sentences explaining the compliance determination
Respond ONLY with a valid JSON object, no preamble, no markdown:
{
"compliance_findings": [
{
"clause_type": "<category>",
"regulation": "<regulation name and section>",
"status": "NON_COMPLIANT|AT_RISK|COMPLIANT",
"detail": "<explanation>"
}
],
"non_compliant_count": <int>
}
""".strip()
class ComplianceCheckerAgent:
def __init__(self, subject, context: Context) -> None:
self.subject = subject
self.context = context
def on_preprocess(self, task: AgentTask) -> Optional[List[AgentTask]]:
risks = task.job_data.get("risks")
if not risks or not isinstance(risks, list):
log.warning("Task %s missing or invalid 'risks' — skipping.", task.task_id)
return None
return [task]
def get_muxer(self):
return None
def on_data(self, task: AgentTask) -> AgentResult:
try:
risks = task.job_data["risks"]
clauses = task.job_data.get("clauses", {})
raw_contract = task.job_data.get("raw_contract", "")
overall_score = task.job_data.get("overall_risk_score", 0)
high_risk_count = task.job_data.get("high_risk_count", 0)
user_message = ("Check the following contract risks for regulatory compliance:\n\n"
+ json.dumps(risks, indent=2))
try:
parsed = call_llm_json(SYSTEM_PROMPT, user_message)
except OpenAIError as e:
log.error("Task %s — OpenAI API error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"OpenAI error: {e}"})
except ValueError as e:
log.error("Task %s — JSON parse error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"JSON parse error: {e}"})
compliance_findings = parsed.get("compliance_findings", [])
non_compliant_count = parsed.get("non_compliant_count",
sum(1 for f in compliance_findings if f.get("status") == "NON_COMPLIANT"))
log.info("Task %s — %d finding(s), %d non-compliant",
task.task_id, len(compliance_findings), non_compliant_count)
return AgentResult(
task_id=task.task_id,
job_output={"raw_contract": raw_contract, "clauses": clauses, "risks": risks,
"overall_risk_score": overall_score, "high_risk_count": high_risk_count,
"compliance_findings": compliance_findings, "non_compliant_count": non_compliant_count},
job_output_metadata={"compliance_findings_count": len(compliance_findings),
"non_compliant_count": non_compliant_count},
is_error=False,
)
except Exception as e:
log.exception("Task %s — unexpected error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": str(e)})
main(ComplianceCheckerAgent)
4.4 Negotiation Advisor Agent
File: negotiation_advisor_agent.py
Input: { risks[], compliance_findings[], clauses{}, raw_contract, overall_risk_score, high_risk_count, non_compliant_count }
Output: adds redlines[], negotiation_stance
Guards: Skips if either risks or compliance_findings is missing.
import json
import logging
from typing import List, Optional
from openai import OpenAIError
from core.agent_executor import AgentResult, AgentTask, Context
from core.main import main
from llm import call_llm_json
log = logging.getLogger(__name__)
SYSTEM_PROMPT = """
You are a commercial contract negotiation specialist representing the client's interests.
You will receive identified risks and compliance findings for a contract.
For each significant issue, produce a concrete redline — a specific suggested replacement
clause that protects the client.
For each redline provide:
- clause_type: the clause category
- priority: HIGH, MEDIUM, or LOW
- original: the problematic language or clause summary
- suggested: the specific replacement language to propose
- rationale: 1-2 sentences explaining how this protects the client
Also provide an overall negotiation_stance:
- REJECT: if the contract is fundamentally unacceptable
- NEGOTIATE: if meaningful redlines can make it acceptable
- ACCEPT_WITH_CHANGES: if only minor tweaks are needed
Respond ONLY with a valid JSON object, no preamble, no markdown:
{
"redlines": [
{
"clause_type": "<category>",
"priority": "HIGH|MEDIUM|LOW",
"original": "<problematic language>",
"suggested": "<replacement language>",
"rationale": "<rationale>"
}
],
"negotiation_stance": "REJECT|NEGOTIATE|ACCEPT_WITH_CHANGES"
}
""".strip()
class NegotiationAdvisorAgent:
def __init__(self, subject, context: Context) -> None:
self.subject = subject
self.context = context
def get_muxer(self):
return None
def on_preprocess(self, task: AgentTask) -> Optional[List[AgentTask]]:
if not task.job_data.get("risks") or not isinstance(task.job_data.get("risks"), list):
log.warning("Task %s missing 'risks' — skipping.", task.task_id)
return None
if not task.job_data.get("compliance_findings") or not isinstance(task.job_data.get("compliance_findings"), list):
log.warning("Task %s missing 'compliance_findings' — skipping.", task.task_id)
return None
return [task]
def on_data(self, task: AgentTask) -> AgentResult:
try:
risks = task.job_data["risks"]
compliance_findings = task.job_data["compliance_findings"]
clauses = task.job_data.get("clauses", {})
raw_contract = task.job_data.get("raw_contract", "")
overall_score = task.job_data.get("overall_risk_score", 0)
high_risk_count = task.job_data.get("high_risk_count", 0)
non_compliant_count = task.job_data.get("non_compliant_count", 0)
user_message = ("Produce redlines for the following risks and compliance findings:\n\n"
"RISKS:\n" + json.dumps(risks, indent=2)
+ "\n\nCOMPLIANCE FINDINGS:\n" + json.dumps(compliance_findings, indent=2))
try:
parsed = call_llm_json(SYSTEM_PROMPT, user_message)
except OpenAIError as e:
log.error("Task %s — OpenAI API error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"OpenAI error: {e}"})
except ValueError as e:
log.error("Task %s — JSON parse error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"JSON parse error: {e}"})
redlines = parsed.get("redlines", [])
negotiation_stance = parsed.get("negotiation_stance", "NEGOTIATE")
log.info("Task %s — %d redline(s), stance=%s", task.task_id, len(redlines), negotiation_stance)
return AgentResult(
task_id=task.task_id,
job_output={"raw_contract": raw_contract, "clauses": clauses, "risks": risks,
"overall_risk_score": overall_score, "high_risk_count": high_risk_count,
"compliance_findings": compliance_findings, "non_compliant_count": non_compliant_count,
"redlines": redlines, "negotiation_stance": negotiation_stance},
job_output_metadata={"redline_count": len(redlines), "negotiation_stance": negotiation_stance},
is_error=False,
)
except Exception as e:
log.exception("Task %s — unexpected error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": str(e)})
main(NegotiationAdvisorAgent)
4.5 Legal Memo Agent
File: legal_memo_agent.py
Input: { risks[], compliance_findings[], redlines[], clauses{}, overall_risk_score, high_risk_count, non_compliant_count, negotiation_stance }
Output: { memo{}, overall_risk_score, negotiation_stance }
Guards: Skips if any of risks, compliance_findings, or redlines is missing.
import json
import logging
from typing import List, Optional
from openai import OpenAIError
from core.agent_executor import AgentResult, AgentTask, Context
from core.main import main
from llm import call_llm_json
log = logging.getLogger(__name__)
SYSTEM_PROMPT = """
You are a senior legal counsel drafting an internal memo for a client's legal team.
You will receive a full contract analysis including extracted clauses, identified risks,
compliance findings, and proposed redlines.
Produce a concise, professional legal memo with the following sections:
- executive_summary: 2-3 sentences summarising the overall situation and recommendation
- key_risks: prose paragraph covering the most critical risks
- compliance_issues: prose paragraph covering any regulatory compliance concerns
- recommended_redlines: prose paragraph summarising the most important redlines to push for
- recommendation: one of REJECT, NEGOTIATE, or ACCEPT_WITH_CHANGES
- next_steps: 3-5 concrete, actionable next steps as a newline-separated list
Respond ONLY with a valid JSON object, no preamble, no markdown:
{
"memo": {
"executive_summary": "<text>",
"key_risks": "<text>",
"compliance_issues": "<text>",
"recommended_redlines": "<text>",
"recommendation": "REJECT|NEGOTIATE|ACCEPT_WITH_CHANGES",
"next_steps": "<text>"
}
}
""".strip()
class LegalMemoAgent:
def __init__(self, subject, context: Context) -> None:
self.subject = subject
self.context = context
def on_preprocess(self, task: AgentTask) -> Optional[List[AgentTask]]:
for field in ("risks", "compliance_findings", "redlines"):
if not task.job_data.get(field):
log.warning("Task %s missing '%s' — skipping.", task.task_id, field)
return None
return [task]
def get_muxer(self):
return None
def on_data(self, task: AgentTask) -> AgentResult:
try:
risks = task.job_data["risks"]
compliance_findings = task.job_data["compliance_findings"]
redlines = task.job_data["redlines"]
clauses = task.job_data.get("clauses", {})
overall_score = task.job_data.get("overall_risk_score", 0)
high_risk_count = task.job_data.get("high_risk_count", 0)
non_compliant_count = task.job_data.get("non_compliant_count", 0)
negotiation_stance = task.job_data.get("negotiation_stance", "NEGOTIATE")
user_message = (
"Draft a legal memo based on the following contract analysis.\n\n"
"CLAUSES:\n" + json.dumps(clauses, indent=2)
+ "\n\nRISKS:\n" + json.dumps(risks, indent=2)
+ "\n\nCOMPLIANCE FINDINGS:\n"+ json.dumps(compliance_findings, indent=2)
+ "\n\nPROPOSED REDLINES:\n" + json.dumps(redlines, indent=2)
+ f"\n\nNEGOTIATION STANCE: {negotiation_stance}"
+ f"\nOVERALL RISK SCORE: {overall_score}/10"
+ f"\nHIGH RISK CLAUSES: {high_risk_count}"
+ f"\nNON-COMPLIANT CLAUSES: {non_compliant_count}"
)
try:
parsed = call_llm_json(SYSTEM_PROMPT, user_message)
except OpenAIError as e:
log.error("Task %s — OpenAI API error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"OpenAI error: {e}"})
except ValueError as e:
log.error("Task %s — JSON parse error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": f"JSON parse error: {e}"})
memo = parsed.get("memo", {})
log.info("Task %s — memo generated | recommendation=%s",
task.task_id, memo.get("recommendation", "N/A"))
return AgentResult(
task_id=task.task_id,
job_output={"memo": memo, "overall_risk_score": overall_score,
"negotiation_stance": negotiation_stance},
job_output_metadata={"recommendation": memo.get("recommendation"),
"overall_risk_score": overall_score},
is_error=False,
)
except Exception as e:
log.exception("Task %s — unexpected error: %s", task.task_id, e)
return AgentResult(task_id=task.task_id, is_error=True,
error_data={"stage": "on_data", "message": str(e)})
main(LegalMemoAgent)
5. Step 2 — Author the Workflow Spec
Save the following as spec.json in your working directory.
{
"header": {
"workflow_id": {
"name": "legal-contract-review-pipeline-1",
"version": "1.0.0",
"release": "stable"
},
"metadata": {
"description": "Multi-agent pipeline to review contracts, flag risks and produce a legal memo",
"owner": "legal-ops-team",
"created_at": "2026-03-09"
}
},
"body": {
"nodes": [
{
"nodeID": "clause-extractor-agent",
"type": "agent",
"id": "w1-clause-1",
"settings": { "model_name": "aios:qwen3-1-7b-vllm-block" }
},
{
"nodeID": "risk-identifier-agent",
"type": "agent",
"id": "w1-risk-1",
"settings": { "model_name": "aios:qwen3-1-7b-vllm-block" }
},
{
"nodeID": "compliance-checker-agent",
"type": "agent",
"id": "w1-comp-1",
"settings": { "model_name": "aios:qwen3-1-7b-vllm-block" }
},
{
"nodeID": "negotiation-advisor-agent",
"type": "agent",
"id": "w1-nego-1",
"settings": { "model_name": "aios:qwen3-1-7b-vllm-block" }
},
{
"nodeID": "legal-memo-agent",
"type": "agent",
"id": "w1-memo-1",
"settings": { "model_name": "aios:qwen3-1-7b-vllm-block" }
}
],
"graph": {
"clause-extractor-agent": ["risk-identifier-agent"],
"risk-identifier-agent": ["compliance-checker-agent"],
"compliance-checker-agent": ["negotiation-advisor-agent"],
"negotiation-advisor-agent": ["legal-memo-agent"]
}
}
}
The workflow_uri derived from this header is:
legal-contract-review-pipeline-1:1.0.0-stable
You will need this exact string in the next step.
6. Step 3 — Register the Workflow
Register the spec with the workflow service. This stores it in the database under its workflow_uri.
curl -X POST \
-d @./spec.json \
http://35.223.239.192:30721/api/workflows
Check: The service returns
{"success": true, "data": {...}}with the stored spec, includingheader.workflow_uri. Registration does not validate the spec. Problems such as duplicatenodeIDvalues or graph references to undefined nodes appear only when the executor starts in the next step. Registering the sameworkflow_uritwice returns HTTP 400; usePUT /api/workflows/<workflow_uri>to replace it.
7. Step 4 — Deploy the Workflow Controller
Deploy a workflow executor that knows where to find the agents and policy engine. The deployer creates a Kubernetes Deployment and a NodePort Service (legal-contract-review-svc) in the workflows namespace.
curl -X POST \
http://35.223.239.192:30721/api/deploy-workflow/deployer-111 \
-H "Content-Type: application/json" \
-d '{
"deployment_name": "legal-contract-review",
"workflow_id": "legal-review-001",
"workflow_uri": "legal-contract-review-pipeline-1:1.0.0-stable",
"allocation": {
"policy_db_url": "http://34.58.1.86:30102",
"delegate_api_url": "http://35.223.239.192:30725"
}
}'
Field reference:
| Field | Value | Notes |
|---|---|---|
deployment_name |
legal-contract-review |
Human-readable label |
workflow_id |
legal-review-001 |
Unique ID of the runtime workflow record (must not already exist) |
workflow_uri |
legal-contract-review-pipeline-1:1.0.0-stable |
Must match the registered spec exactly |
policy_db_url |
http://34.58.1.86:30102 |
Rules engine endpoint |
delegate_api_url |
http://35.223.239.192:30725 |
Agent execution endpoint |
Response — note the url; it is the executor address used in the next step:
{
"success": true,
"data": {
"id": "legal-review-001",
"workflow_uri": "legal-contract-review-pipeline-1:1.0.0-stable",
"cluster_id": null,
"deployer_id": "deployer-111",
"deployment_name": "legal-contract-review",
"url": "http://35.223.239.192:30712"
}
}
Critical: The
workflow_urimust be an exact match — including name, version, and release. On a mismatch the deploy call still succeeds, but the executor cannot fetch the spec at start-up (WorkflowDBError) and keeps restarting.
8. Step 5 — Execute a Task
Submit a contract for review to the executor url returned by the deploy step. Pass the raw contract text in the text field. The call is synchronous and returns when all five agents have finished.
curl -X POST \
http://35.223.239.192:30712/api/execute \
-H "Content-Type: application/json" \
-d '{
"text": "Contract: SaaS Subscription Agreement between Acme Corp (vendor) and RetailCo (client). Key clauses: auto-renewal at 15% price increase, no SLA guarantees, data ownership ambiguous, termination requires 180-day notice, liability cap at $5,000 regardless of contract value ($2M/year). Jurisdiction: Delaware, USA."
}'
Input requirements:
| Field | Type | Required | Notes |
|---|---|---|---|
text |
string | Yes | Raw contract text. Must be at least 20 characters. |
Note: The
clause-extractor-agentis the entry node — it is the only agent that reads fromtext. All subsequent agents read from the structured output produced by their upstream agent.
9. Understanding the Output
The response contains the output of every node, keyed by nodeID:
{
"success": true,
"data": {
"clause-extractor-agent": { "raw_contract": "...", "clauses": { ... }, "clause_count": 6 },
"risk-identifier-agent": { "...": "..." },
"compliance-checker-agent": { "...": "..." },
"negotiation-advisor-agent": { "...": "...", "redlines": [ ... ], "negotiation_stance": "NEGOTIATE" },
"legal-memo-agent": { "memo": { ... }, "overall_risk_score": 8, "negotiation_stance": "NEGOTIATE" }
}
}
The final result is data["legal-memo-agent"]:
{
"memo": {
"executive_summary": "High-level summary and overall recommendation.",
"key_risks": "Prose covering the most critical identified risks.",
"compliance_issues": "Prose covering GDPR, CCPA, or Delaware law concerns.",
"recommended_redlines": "Prose summarising the most important clauses to renegotiate.",
"recommendation": "REJECT | NEGOTIATE | ACCEPT_WITH_CHANGES",
"next_steps": "Step 1\nStep 2\nStep 3"
},
"overall_risk_score": 8,
"negotiation_stance": "NEGOTIATE"
}
Recommendation values:
| Value | Meaning |
|---|---|
REJECT |
Contract is fundamentally unacceptable — do not proceed |
NEGOTIATE |
Meaningful redlines can make it acceptable |
ACCEPT_WITH_CHANGES |
Only minor tweaks needed |
10. Error Handling
All agents use the same error handling pattern:
| Error Type | Cause | Behaviour |
|---|---|---|
on_preprocess returns None |
Required input field missing or invalid | Agent is skipped — no result is produced |
OpenAIError |
LLM API call failed | Returns AgentResult(is_error=True) with error details |
ValueError |
LLM response could not be parsed as JSON | Returns AgentResult(is_error=True) with error details |
Exception |
Any other unexpected error | Logged with full traceback, returns AgentResult(is_error=True) |
What the workflow executor does with these:
- Error result (
is_error=True). The executor does not checkis_error. It passes the agent'sjob_output— empty for an error result — to the next agent as its input. - Skipped agent (
on_preprocessreturnsNone). The agent sends no result back. The Delegate API waits on the session's result queue without a timeout, so the workflow executor waits too, and/api/executedoes not return.
So the cascade does not end cleanly. If one agent fails, the next agent receives {"model_name": ...}, finds its required fields missing, and skips. The workflow then hangs.
Recommendation: In agents used by workflows, return an error
AgentResultfromon_data(or emit an error result instead of returningNonefromon_preprocess). Check the agent logs and thejob_outputof each node when debugging.
11. End-to-End Checklist
□ 1. All five agents are written, created as subjects (w1-clause-1, w1-risk-1, w1-comp-1,
w1-nego-1, w1-memo-1) and deployed, with OPENAI_API_KEY set in their environment
□ 2. spec.json is saved with the correct header and graph
□ 3. Workflow registered:
curl -X POST -d @./spec.json http://35.223.239.192:30721/api/workflows
□ 4. Executor deployed:
curl -X POST http://35.223.239.192:30721/api/deploy-workflow/deployer-111 ...
workflow_uri = "legal-contract-review-pipeline-1:1.0.0-stable"
note data.url from the response
□ 5. Task submitted:
curl -X POST <data.url>/api/execute ...
body = { "text": "<contract text>" }
□ 6. Output reviewed — check data["legal-memo-agent"].memo.recommendation and overall_risk_score