Skip to content

Materializer pattern

A Materializer transforms a logical artifact (agent, hook, skill) into files on disk inside a project folder. The pattern is codified in ADR 0003: the core never writes these artifacts directly; it goes through a dedicated port.


Without a Materializer, the core would write .claude/agents/*.md, .claude/hooks/*.json, etc. directly. That would create several problems:

  • Coupling: the core would know the agent file format (Markdown YAML, JSON, etc.) — that’s not its job.
  • Boundaries: impossible to mock writes for tests without patching fs.
  • Evolution: changing the format (e.g. adding a signature) would require touching the core.

The Materializer isolates all that behind a port:

core/ports/outbound/agent-workspace-materializer.ts
export interface AgentWorkspaceMaterializer {
materialize(input: MaterializeAgentInput): Promise<MaterializedAgent>;
unmaterialize(input: { projectPath: string; sourceId: string }): Promise<void>;
list(projectPath: string): Promise<readonly MaterializedAgent[]>;
}

The buildForCatalogue use-case consumes this port. The concrete ClaudeAgentWorkspaceMaterializer implementation knows the Claude Code format.


MaterializerTargetOutput format
ClaudeAgentWorkspaceMaterializer.claude/agents/<slug>.mdMarkdown + YAML frontmatter
HookWorkspaceMaterializer.claude/hooks/<name>.jsonJSON
SkillWorkspaceMaterializer.claude/skills/<name>/Folder + manifest

All write into the project folder (<projectPath>/.claude/), never elsewhere. The Filesystem allowlist validates paths.


  • You want to expose a logical artifact (e.g. “governance policy”) as a file consumable by an external tool (an editor, an AI agent, a CI).
  • The output format can evolve without affecting the core.
  • Several targets may exist (e.g. an agent for Claude Code + an agent for Codex CLI → two Materializers).

Avoid the Materializer pattern for purely internal writes (chat sessions, memory) — those go through their dedicated store.


Install agent (forCatalogue.install use-case)
[Read profile from Cortex]
[Materializer.materialize → write .claude/agents/<slug>.md]
[Update <project>/.arka-deck/agents/installed.json]
EventBus → 'agent.installed' (async)
Remove agent (forCatalogue.remove)
[Materializer.unmaterialize → delete .claude/agents/<slug>.md]
[Update installed.json]
EventBus → 'agent.removed' (async)

The installed.json tracking allows safe purge: only tracked files are deleted, never user artifacts (other non-arka-deck agents in .claude/agents/).


The concrete implementation is testable like any adapter. For consuming use-cases, you inject a FakeAgentWorkspaceMaterializer:

const fakeMaterializer: AgentWorkspaceMaterializer = {
async materialize(input) { return { /* ... */ }; },
async unmaterialize() {},
async list() { return []; },
};
const forCatalogue = buildForCatalogue({
catalogueClient,
agentMaterializer: fakeMaterializer,
// ...
});