Developer composition orchestration architecture

Skill Composition

Orchestrating multiple skills together: dependency resolution, parallel execution, shared context, and building complex capabilities from simple parts.

Individual skills are the atoms of agent behavior. Skill composition is the chemistry: the rules and patterns for combining simple skills into complex capabilities that none of them could pull off alone. A file search skill, a code analysis skill, and a report generation skill are useful by themselves. Composed together, they become an automated code review system.

This article covers the architectural patterns for skill composition: how to resolve dependencies between skills, when to run them in parallel versus sequentially, how to share context between composed skills, and how to design skills that compose well from the start.

The composition mindset

Before getting into implementation, it’s worth understanding why composition matters more than building bigger individual skills.

Consider two approaches to building an automated code review:

Approach A: monolithic skill. Build a single review_code skill that reads files, runs linters, checks test coverage, analyzes complexity, and generates a summary.

Approach B: composed skills. Build five focused skills (read_files, run_linter, check_coverage, analyze_complexity, generate_summary) and compose them.

Approach B wins on every dimension that matters in practice:

  • Each skill can be tested independently with simple inputs and outputs
  • run_linter is useful outside of code review (CI pipelines, pre-commit hooks, etc.)
  • Updating the linter doesn’t require touching the coverage checker
  • The agent can skip steps, reorder them, or swap in alternatives
  • When something fails, you know exactly which skill failed and why

This is the same principle behind Unix pipes, microservices, and functional composition. Small, focused units combined through well-defined interfaces produce more reliable systems than monolithic alternatives. For the opposite of this, see the God Skill section in Anti-Patterns to Avoid.

Dependency resolution between skills

When composing skills, some have to run before others. The generate_summary skill needs outputs from run_linter and check_coverage. The check_coverage skill needs the test files identified by read_files. These dependencies form a directed acyclic graph (DAG).

Defining dependencies explicitly

interface ComposableSkill {
  name: string;
  dependencies: string[];
  execute: (inputs: Record<string, unknown>) => Promise<unknown>;
}

const codeReviewSkills: ComposableSkill[] = [
  {
    name: "read_files",
    dependencies: [],
    execute: async (inputs) => {
      return invoke("read_files", { path: inputs.prPath });
    },
  },
  {
    name: "run_linter",
    dependencies: ["read_files"],
    execute: async (inputs) => {
      return invoke("run_linter", { files: inputs.read_files.filePaths });
    },
  },
  {
    name: "check_coverage",
    dependencies: ["read_files"],
    execute: async (inputs) => {
      return invoke("check_coverage", { testDir: inputs.read_files.testDir });
    },
  },
  {
    name: "analyze_complexity",
    dependencies: ["read_files"],
    execute: async (inputs) => {
      return invoke("analyze_complexity", {
        files: inputs.read_files.filePaths,
      });
    },
  },
  {
    name: "generate_summary",
    dependencies: ["run_linter", "check_coverage", "analyze_complexity"],
    execute: async (inputs) => {
      return invoke("generate_summary", {
        lintResults: inputs.run_linter,
        coverageResults: inputs.check_coverage,
        complexityResults: inputs.analyze_complexity,
      });
    },
  },
];

Topological execution

A DAG executor runs skills in dependency order, making sure each skill’s inputs are ready before it starts.

async function executeDAG(
  skills: ComposableSkill[],
  initialInputs: Record<string, unknown>,
): Promise<Record<string, unknown>> {
  const results: Record<string, unknown> = { ...initialInputs };
  const completed = new Set<string>();
  const remaining = [...skills];

  while (remaining.length > 0) {
    // Find all skills whose dependencies are satisfied
    const ready = remaining.filter((skill) =>
      skill.dependencies.every((dep) => completed.has(dep)),
    );

    if (ready.length === 0) {
      const unresolved = remaining.map((s) => s.name);
      throw new Error(
        `Circular dependency detected among: ${unresolved.join(", ")}`,
      );
    }

    // Execute all ready skills in parallel
    const executions = ready.map(async (skill) => {
      const result = await skill.execute(results);
      return { name: skill.name, result };
    });

    const outcomes = await Promise.all(executions);

    for (const { name, result } of outcomes) {
      results[name] = result;
      completed.add(name);
      const idx = remaining.findIndex((s) => s.name === name);
      remaining.splice(idx, 1);
    }
  }

  return results;
}

This executor automatically parallelizes skills that don’t depend on each other. In the code review example, run_linter, check_coverage, and analyze_complexity all depend only on read_files, so they run concurrently once read_files finishes.

Parallel vs. sequential execution

The DAG executor above handles the easy case: skills with explicit dependencies get sequenced, everything else runs in parallel. But there are subtler considerations.

When to force sequential execution

Even when skills have no data dependency, you might still need to sequence them:

ScenarioWhy you’d sequence it
Both skills write to the same fileAvoid write conflicts
One skill consumes limited API quotaPrevent rate limit exhaustion
Skills share an external resource (DB connection)Avoid contention
Order-dependent side effectsLater skill assumes earlier skill’s side effects
class ExecutionPolicy:
    """Controls whether skills can run in parallel."""

    def __init__(self):
        self.mutual_exclusions: list[tuple[str, str]] = []
        self.resource_locks: dict[str, list[str]] = {}

    def add_exclusion(self, skill_a: str, skill_b: str) -> None:
        """Skills A and B must not run at the same time."""
        self.mutual_exclusions.append((skill_a, skill_b))

    def add_resource_lock(self, resource: str, skills: list[str]) -> None:
        """Only one of these skills can use this resource at a time."""
        self.resource_locks[resource] = skills

    def can_run_parallel(self, skill_a: str, skill_b: str) -> bool:
        """Check if two skills can run concurrently."""
        for a, b in self.mutual_exclusions:
            if (skill_a == a and skill_b == b) or (skill_a == b and skill_b == a):
                return False

        for resource, skills in self.resource_locks.items():
            if skill_a in skills and skill_b in skills:
                return False

        return True

Bounded parallelism

Running everything in parallel at once can overwhelm resources. Use a semaphore or concurrency pool to cap the number of simultaneous skill executions.

async function executeWithConcurrencyLimit<T>(
  tasks: (() => Promise<T>)[],
  maxConcurrency: number,
): Promise<T[]> {
  const results: T[] = [];
  let index = 0;

  async function worker(): Promise<void> {
    while (index < tasks.length) {
      const currentIndex = index++;
      results[currentIndex] = await tasks[currentIndex]();
    }
  }

  const workers = Array.from(
    { length: Math.min(maxConcurrency, tasks.length) },
    () => worker(),
  );

  await Promise.all(workers);
  return results;
}

// Usage: run up to 3 skills at a time
const analyses = await executeWithConcurrencyLimit(
  [
    lintAnalysis,
    coverageAnalysis,
    complexityAnalysis,
    securityScan,
    dependencyCheck,
  ],
  3,
);

Shared context and state passing

Composed skills need to share data. There are three main patterns for this, each with different trade-offs.

Pattern 1: explicit parameter passing

Each skill declares exactly what it needs and what it produces. Data flows through parameters.

// Each skill takes explicit inputs and returns explicit outputs
const lintResult = await invoke("run_linter", { files: changedFiles });
const coverageResult = await invoke("check_coverage", { testDir });
const summary = await invoke("generate_summary", {
  lintIssues: lintResult.issues,
  coveragePercent: coverageResult.percent,
});

Pros: explicit data flow, easy to test, no hidden dependencies. Cons: verbose when many skills need the same data.

Pattern 2: shared context object

Skills read from and write to a shared context object, similar to the workflow context in Multi-Step Workflows.

class CompositionContext:
    """Shared state for composed skills."""

    def __init__(self):
        self._state: dict[str, Any] = {}
        self._access_log: list[tuple[str, str, str]] = []  # (skill, key, operation)

    def set(self, skill_name: str, key: str, value: Any) -> None:
        self._state[key] = value
        self._access_log.append((skill_name, key, "write"))

    def get(self, skill_name: str, key: str) -> Any:
        if key not in self._state:
            raise KeyError(f"Skill '{skill_name}' requested key '{key}' which has not been set")
        self._access_log.append((skill_name, key, "read"))
        return self._state[key]

    def get_dependency_graph(self) -> dict[str, list[str]]:
        """Infer which skills depend on which based on read/write patterns."""
        writers: dict[str, str] = {}
        dependencies: dict[str, set[str]] = {}

        for skill, key, operation in self._access_log:
            if operation == "write":
                writers[key] = skill
            elif operation == "read" and key in writers:
                if skill not in dependencies:
                    dependencies[skill] = set()
                dependencies[skill].add(writers[key])

        return {k: list(v) for k, v in dependencies.items()}

Pros: less boilerplate, skills can discover available data. Cons: implicit dependencies, harder to test skills in isolation.

Pattern 3: event-based communication

Skills publish results as events. Other skills subscribe to the events they care about. This is the most decoupled approach.

class SkillEventBus {
  private listeners = new Map<string, ((data: unknown) => void)[]>();
  private history: { event: string; data: unknown; timestamp: Date }[] = [];

  emit(event: string, data: unknown): void {
    this.history.push({ event, data, timestamp: new Date() });
    const handlers = this.listeners.get(event) || [];
    for (const handler of handlers) {
      handler(data);
    }
  }

  on(event: string, handler: (data: unknown) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }
    this.listeners.get(event)!.push(handler);
  }

  getHistory(event?: string): typeof this.history {
    if (event) return this.history.filter((h) => h.event === event);
    return this.history;
  }
}

Pros: maximum decoupling, skills don’t need to know about each other. Cons: harder to follow the data flow, potential ordering issues.

Which pattern to choose

SituationRecommended pattern
Small composition (2-4 skills)Explicit parameter passing
Medium composition (5-10 skills)Shared context object
Large or dynamic compositionEvent-based communication
Skills from different authors or teamsExplicit parameter passing
Skills that will be reused across compositionsExplicit parameter passing

In practice, explicit parameter passing is the right default. It’s the most transparent, most testable, and easiest to debug. Move to shared context when parameter passing gets unwieldy, and to event-based only when you genuinely need decoupling between independent skill providers.

Designing skills that compose well

The skills you build today will be composed in ways you haven’t thought of yet. Designing for composability from the start saves a lot of rework later.

Principles for composable skills

  1. Single responsibility. Each skill does one thing. If you catch yourself adding “and also” to a skill’s description, split it. See Tool Use Patterns for guidance on writing clear, focused skill descriptions.

  2. Typed inputs and outputs. Use typed schemas for both parameters and return values. The agent and other skills need to know exactly what they’re working with.

  3. No hidden side effects. If a skill writes a file, deletes a resource, or sends a notification, make that explicit in the description and return value. Skills with hidden side effects break composition because the orchestrator can’t reason about what happened.

  4. Idempotent when possible. A skill that can be safely called twice with the same input is much easier to compose into workflows with retry logic and error recovery.

  5. Accept the minimum, return the maximum. Take only the parameters you need (don’t force the caller to construct a complex object when a string will do), but return rich results that give downstream skills options for what to consume.

// Good: minimal input, rich output
async function analyzeFile(path: string): Promise<FileAnalysis> {
  const content = await readFile(path);
  return {
    path,
    language: detectLanguage(path),
    lineCount: content.split("\n").length,
    functions: extractFunctions(content),
    imports: extractImports(content),
    exports: extractExports(content),
    complexity: calculateComplexity(content),
  };
}

// Bad: requires complex input, returns minimal output
async function analyzeFile(options: {
  path: string;
  content: string;
  language: string;
  projectRoot: string;
  config: AnalysisConfig;
}): Promise<{ score: number }> {
  // ...
}

The best skill architectures look less like a Swiss Army knife and more like a set of Lego bricks. Each piece is simple and self-contained. The interesting part is how they snap together. If you design every skill to take clean inputs, return rich outputs, and avoid hidden side effects, you’ll find they compose in ways you never planned for. That’s the whole point: small, honest building blocks combine into capabilities that no monolithic skill could match.

For a hands-on example of skill composition in practice, Building a Slack Bot shows how multiple skills (message handling, user lookup, channel management) compose into a working integration.