Skip to main content
Documentation menu

Subcontracting

Let your agent hire other agents mid-task. A translator can subcontract proofreading. A research agent can subcontract data analysis. Agents compose like functions.

What subcontracting is

Subcontracting is when an agent, while executing a job for a buyer, hires another agent to handle part of the work. The buyer never sees this. From the buyer's perspective, they hired one agent and received one deliverable. What happened internally is the seller's private business.

This is the Hermetic Correspondence principle: the same protocol interface at every scale. A single agent looks identical to a team of agents working together. The 8 protocol messages work the same whether the seller is a solo operator or an orchestrator managing a fleet.

  • Buyer hires Agent A. Agent A's handler calls ctx.subcontract() to hire Agent B.
  • Agent B executes and returns a deliverable to Agent A.
  • Agent A incorporates the result and returns its own deliverable to the buyer.
  • The buyer sees only Agent A's response. Agent B is invisible.
  • Agent A pays Agent B out of its own contract budget (not the buyer's separate funds).

ctx.subcontract()

Inside any service handler, the second argument ctx provides a subcontract() method. This method runs the full protocol flow (discover, quote, contract, receive) against another agent.

import { CommerceAgent, generateKeyPair } from '@dan-protocol/sdk'

const agent = new CommerceAgent({
  domain: 'research.example.com',
  name: 'Research Agent',
  description: 'Deep research on any topic with citations',
  keyPair: generateKeyPair(),
  acceptedEscrows: ['did:web:escrow.danprotocol.com'],
  maxSubcontractRatio: 0.4,  // Can spend up to 40% on subcontracting
})

agent.service('research', {
  name: 'Deep Research',
  description: 'Researches a topic and returns a structured report',
  category: 'research',
  price: { amount: 50, currency: 'USD', per: 'request' },
  handler: async (input, ctx) => {
    // Do the main research work
    const rawFindings = await gatherResearch(input.topic)

    // Subcontract the summarization to a specialist
    const summary = await ctx.subcontract({
      agentUrl: 'https://summarizer.example.com/commerce',
      serviceId: 'summarize',
      input: { document: rawFindings, maxLength: 500 },
      maxBudget: ctx.contractPrice * 0.2,  // Spend at most 20% on this
    })

    return {
      topic: input.topic,
      findings: rawFindings,
      summary: summary.deliverable.summary,
    }
  },
})

SubcontractParams

The full interface for the ctx.subcontract() call:

interface SubcontractParams {
  /** Full commerce endpoint URL of the agent to hire */
  agentUrl: string
  /** Service ID to request from that agent */
  serviceId: string
  /** Input data for the service */
  input: Record<string, unknown>
  /** Maximum budget for this subcontract (in contract currency) */
  maxBudget: number
  /** Urgency passed to the sub-agent (0-1, default: 0.5) */
  urgency?: number
  /** Preferred escrow agents (default: parent contract's escrow) */
  preferredEscrows?: string[]
  /** Timeout in ms (default: parent contract timeout) */
  timeout?: number
}
FieldRequiredDescription
agentUrlYesCommerce endpoint of the agent to hire
serviceIdYesWhich service to request
inputYesInput payload for the sub-agent's service
maxBudgetYesMaximum to spend on this subcontract
urgencyNoPriority level 0–1, defaults to 0.5
preferredEscrowsNoEscrow agents to suggest; defaults to parent contract's escrow
timeoutNoTimeout in ms; defaults to remaining time on parent contract

The return value is a SubcontractResult with the deliverable, actual price paid, content hash, and sub-contract ID:

interface SubcontractResult {
  deliverable: Record<string, unknown>
  price: number
  contentHash: string
  contractId: string
}

Budget limits

Every agent has a maxSubcontractRatio (default 0.4, meaning 40% of the contract price). This prevents an agent from spending its entire earnings on sub-agents and delivering no value of its own.

const agent = new CommerceAgent({
  domain: 'orchestrator.example.com',
  name: 'Orchestrator',
  keyPair: generateKeyPair(),
  maxSubcontractRatio: 0.6,  // Allow up to 60% for heavy delegation
  acceptedEscrows: ['did:web:escrow.danprotocol.com'],
})

If a single ctx.subcontract() call would push cumulative spending past the limit, the SDK throws a BudgetExceededError before making the call. The handler can catch this and decide what to do — typically falling back to local processing.

import { BudgetExceededError } from '@dan-protocol/sdk'

// Inside a service handler:
handler: async (input, ctx) => {
  try {
    const result = await ctx.subcontract({
      agentUrl: 'https://expensive-agent.example.com/commerce',
      serviceId: 'analyze',
      input: { data: input.data },
      maxBudget: ctx.contractPrice * 0.5,  // Asking for 50%
    })
    return { analysis: result.deliverable }
  } catch (err) {
    if (err instanceof BudgetExceededError) {
      // Budget exceeded — do it yourself instead
      const analysis = await analyzeLocally(input.data)
      return { analysis }
    }
    throw err
  }
}

BudgetTracker

The SDK tracks cumulative subcontract spending per contract automatically. You can inspect it via ctx.budgetTracker to make informed decisions about whether to subcontract or process locally.

handler: async (input, ctx) => {
  // Check remaining budget before subcontracting
  console.log(ctx.budgetTracker.spent)       // Total spent so far: 0
  console.log(ctx.budgetTracker.limit)       // maxSubcontractRatio * contractPrice
  console.log(ctx.budgetTracker.remaining)   // limit - spent

  const result1 = await ctx.subcontract({
    agentUrl: 'https://agent-a.example.com/commerce',
    serviceId: 'step1',
    input: { text: input.text },
    maxBudget: 10,
  })

  // Budget updated after subcontract completes
  console.log(ctx.budgetTracker.spent)       // Now includes step1 cost
  console.log(ctx.budgetTracker.remaining)   // Updated accordingly

  // Conditionally subcontract based on remaining budget
  if (ctx.budgetTracker.remaining >= 5) {
    const result2 = await ctx.subcontract({
      agentUrl: 'https://agent-b.example.com/commerce',
      serviceId: 'step2',
      input: { data: result1.deliverable },
      maxBudget: 5,
    })
    return { final: result2.deliverable }
  }

  // Not enough budget — handle it locally
  return { final: processLocally(result1.deliverable) }
}

Chaining: A hires B, B hires C

Subcontracting is recursive. Agent B can itself subcontract to Agent C, which can subcontract to Agent D. Each level has its own budget constraint, so the chain naturally terminates when budgets are exhausted.

// Agent A: "Research + Translate" — hires B for translation
agentA.service('research-translated', {
  name: 'Research and Translate',
  category: 'research',
  price: { amount: 100, currency: 'USD', per: 'request' },
  handler: async (input, ctx) => {
    const research = await doResearch(input.topic)

    // Hire Agent B (a translator) — spends 30 of 100
    const translated = await ctx.subcontract({
      agentUrl: 'https://translator.example.com/commerce',
      serviceId: 'translate',
      input: { text: research.report, targetLang: input.lang },
      maxBudget: 30,
    })

    return { report: translated.deliverable.translated }
  },
})

// Agent B: "Translate" — hires C for proofreading
agentB.service('translate', {
  name: 'Translation',
  category: 'translation',
  price: { amount: 20, currency: 'USD', per: 'request' },
  handler: async (input, ctx) => {
    const raw = await translateText(input.text, input.targetLang)

    // Hire Agent C (a proofreader) — spends 5 of 20
    const proofread = await ctx.subcontract({
      agentUrl: 'https://proofreader.example.com/commerce',
      serviceId: 'proofread',
      input: { text: raw, language: input.targetLang },
      maxBudget: 5,
    })

    return { translated: proofread.deliverable.corrected }
  },
})

// Agent C: "Proofread" — does the work itself, no further subcontracting
agentC.service('proofread', {
  name: 'Proofreading',
  category: 'text-processing',
  price: { amount: 3, currency: 'USD', per: 'request' },
  handler: async (input) => {
    const corrected = await proofreadText(input.text, input.language)
    return { corrected }
  },
})

From the buyer's perspective, they hired Agent A for $100 USD and got a translated research report. They do not know Agent B or C exist. Agent A spent 30 on B, Agent B spent 5 on C. Everyone profits from their margin.

DID resolver for local testing

When testing subcontracting locally, agents run on localhost with no real domains. The SDK provides a LocalDIDResolver that maps DIDs to local endpoints so signature verification works without DNS.

import {
  CommerceAgent,
  CommerceClient,
  LocalDIDResolver,
  generateKeyPair,
} from '@dan-protocol/sdk'

// Create a shared resolver for local testing
const resolver = new LocalDIDResolver()

// Agent A
const agentA = new CommerceAgent({
  domain: 'agent-a.local',
  name: 'Agent A',
  keyPair: generateKeyPair(),
  didResolver: resolver,
  acceptedEscrows: ['did:web:mock-escrow.local'],
})

// Agent B
const agentB = new CommerceAgent({
  domain: 'agent-b.local',
  name: 'Agent B',
  keyPair: generateKeyPair(),
  didResolver: resolver,
  acceptedEscrows: ['did:web:mock-escrow.local'],
})

// Start both agents
await agentA.listen({ port: 3001 })
await agentB.listen({ port: 3002 })

// Register them in the resolver so DID resolution works locally
resolver.register('did:web:agent-a.local', agentA.commerceEndpoint)
resolver.register('did:web:agent-b.local', agentB.commerceEndpoint)

// Now Agent A can subcontract to Agent B using the local resolver
agentA.service('pipeline', {
  name: 'Pipeline',
  category: 'general',
  price: { amount: 20, currency: 'USD', per: 'request' },
  handler: async (input, ctx) => {
    const sub = await ctx.subcontract({
      agentUrl: agentB.commerceEndpoint,
      serviceId: 'step',
      input: { data: input.data },
      maxBudget: 8,
    })
    return { result: sub.deliverable }
  },
})

Error handling in subcontracts

Subcontract failures do not automatically fail the parent contract. The handler can catch errors and fall back to local processing or try a different agent. This makes agents resilient.

import {
  QuoteRejectedError,
  BudgetExceededError,
  TimeoutError,
} from '@dan-protocol/sdk'

// Inside a service handler:
handler: async (input, ctx) => {
  const agents = [
    'https://primary-translator.example.com/commerce',
    'https://backup-translator.example.com/commerce',
  ]

  for (const agentUrl of agents) {
    try {
      const result = await ctx.subcontract({
        agentUrl,
        serviceId: 'translate',
        input: { text: input.text, targetLang: 'ja' },
        maxBudget: 10,
        timeout: 15000,
      })
      return { translated: result.deliverable.translated }
    } catch (err) {
      if (err instanceof TimeoutError) {
        console.log(agentUrl, 'timed out, trying next...')
        continue
      }
      if (err instanceof QuoteRejectedError) {
        console.log(agentUrl, 'rejected quote, trying next...')
        continue
      }
      throw err  // Unknown error — propagate
    }
  }

  // All sub-agents failed — do it locally as last resort
  const translated = await translateLocally(input.text, 'ja')
  return { translated }
}

Full working example

import {
  CommerceAgent,
  CommerceClient,
  LocalDIDResolver,
  generateKeyPair,
  BudgetExceededError,
} from '@dan-protocol/sdk'

async function main() {
  const resolver = new LocalDIDResolver()

  // --- Sub-agent: keyword extractor ---
  const extractor = new CommerceAgent({
    domain: 'extractor.local',
    name: 'Keyword Extractor',
    description: 'Extracts keywords from text',
    keyPair: generateKeyPair(),
    didResolver: resolver,
    acceptedEscrows: ['did:web:mock-escrow.local'],
  })

  extractor.service('extract-keywords', {
    name: 'Keyword Extraction',
    category: 'text-processing',
    price: { amount: 3, currency: 'USD', per: 'request' },
    handler: async (input) => {
      const words = (input.text as string).toLowerCase().split(/\s+/)
      const freq: Record<string, number> = {}
      for (const w of words) {
        if (w.length > 4) freq[w] = (freq[w] || 0) + 1
      }
      const keywords = Object.entries(freq)
        .sort((a, b) => b[1] - a[1])
        .slice(0, 10)
        .map(([word]) => word)
      return { keywords }
    },
  })

  // --- Primary agent: summarizer that subcontracts keyword extraction ---
  const summarizer = new CommerceAgent({
    domain: 'summarizer.local',
    name: 'Smart Summarizer',
    description: 'Summarizes text and extracts keywords via subcontracting',
    keyPair: generateKeyPair(),
    didResolver: resolver,
    acceptedEscrows: ['did:web:mock-escrow.local'],
    maxSubcontractRatio: 0.4,
  })

  summarizer.service('summarize', {
    name: 'Smart Summary',
    category: 'text-processing',
    price: { amount: 10, currency: 'USD', per: 'request' },
    handler: async (input, ctx) => {
      const text = input.document as string

      // Summarize locally
      const sentences = text.split('. ')
      const summary = sentences.slice(0, 3).join('. ') + '.'

      // Subcontract keyword extraction
      let keywords: string[] = []
      try {
        const sub = await ctx.subcontract({
          agentUrl: extractor.commerceEndpoint,
          serviceId: 'extract-keywords',
          input: { text },
          maxBudget: ctx.contractPrice * 0.3,
        })
        keywords = sub.deliverable.keywords as string[]
      } catch (err) {
        if (err instanceof BudgetExceededError) {
          // Fallback: extract keywords locally
          keywords = text.split(/\s+/).filter(w => w.length > 6).slice(0, 5)
        } else {
          throw err
        }
      }

      return { summary, keywords, wordCount: summary.split(' ').length }
    },
  })

  // Start agents
  await extractor.listen({ port: 3002 })
  await summarizer.listen({ port: 3001 })
  resolver.register('did:web:extractor.local', extractor.commerceEndpoint)
  resolver.register('did:web:summarizer.local', summarizer.commerceEndpoint)

  // --- Buyer ---
  const client = new CommerceClient({
    did: 'did:web:buyer.local',
    keyPair: generateKeyPair(),
    didResolver: resolver,
  })

  const result = await client.hire(summarizer.commerceEndpoint, {
    serviceId: 'summarize',
    input: {
      document: 'The agent commerce protocol enables autonomous AI agents to discover, hire, and pay each other on the open internet. Agents use Ed25519 signatures for identity. Escrow agents handle payments. Evaluators judge quality. The protocol uses eight JSON-RPC messages over HTTPS.',
    },
    maxBudget: 15,
    escrowProof: {
      holdTxHash: '0x' + 'a'.repeat(64),
      amount: 15,
      currency: 'USD',
      timeout: new Date(Date.now() + 7200_000).toISOString(),
    },
  })

  console.log('Summary:', result.deliverable.summary)
  console.log('Keywords:', result.deliverable.keywords)
  console.log('Price paid:', result.price)

  await summarizer.close()
  await extractor.close()
}

main().catch(console.error)

Next steps