Defining artifact types¶
An artifact is anything people and agents work on together: a document, a plan, a spec, a table of data. The library provides versioning, patches, validation, change history and agent tools; your application provides the types. This page covers how to define them and the hooks that shape how they behave. The decision behind this design is ADR-0003.
A type is a Pydantic model¶
Subclass Artifact and declare ordinary Pydantic fields:
from typing import ClassVar, Literal
from pydantic import BaseModel
from artifactr import Artifact, WritePolicy, new_id
class Task(BaseModel):
title: str
status: Literal["todo", "doing", "done"] = "todo"
class Plan(Artifact): # registered as "plan"
write_policy: ClassVar[WritePolicy] = "propose"
goal: str = ""
tasks: dict[str, Task] = {}
def add_task(self, title: str) -> str:
task_id = new_id("task")
self.tasks[task_id] = Task(title=title)
return task_id
As in any Pydantic model, fields without defaults must be given when the artifact is created. Artifact sets extra="forbid" and validate_assignment=True, so unknown fields and invalid assignments fail early. Methods such as add_task are yours: they make edits read well in application code and tools.
Prefer dictionaries keyed by id over lists for collections that change. Edits are recorded as JSON Patch, and a patch that says "set /tasks/task_3f9c.../status" still means the same task after someone else inserts one; "set /tasks/2/status" may not.
Names and registration¶
Defining the class registers it, in Pydantic's __pydantic_init_subclass__ hook. The registered name, available as Plan.kind, is the class name in snake case. It is stored with every artifact and sent on the wire, so choose it deliberately:
from artifactr import MarkdownArtifact
class ReleaseNotes(MarkdownArtifact): # registered as "release_notes"
pass
class Spec(Artifact, name="product_spec"): # an explicit name
title: str
class Document(MarkdownArtifact, abstract=True): # a shared base; not registered
title: str = ""
class Memo(Document): # registered as "memo"
pass
Registering a different class under a name that is already taken raises TypeError; pass name= to choose another. artifactr.core.artifact_types() returns every registered type, and get_artifact_type(kind) looks one up.
Registration is process-wide, so a workspace does not accept every registered type by default. List the types your application accepts when you create Workspaces, and creating any other type is rejected, even one registered by a library you imported:
from artifactr import InMemoryStorage, Workspaces
workspaces = Workspaces(InMemoryStorage(), types=[Plan, ReleaseNotes])
Write policies¶
write_policy decides how an agent's changes to this type are applied:
| Policy | An agent's create, edit or archive |
|---|---|
"direct" (the default) |
is applied, and returns Applied |
"propose" |
is recorded as a proposal, and returns Proposed |
Write policies bind agents only: the thread's built-in agent and external agents connected over MCP. Changes by people and by the system always apply directly. A thread can also be switched into suggest mode (SetThreadMode(thread_id=..., mode="suggest")), which makes its agent propose every change whatever the type's policy. Anyone, person or agent, can propose explicitly with ProposeChange. Workspaces, commits and the log covers how proposals are answered, and The agent covers how the agent hears the outcome.
What the agent sees¶
render_for_agent() returns the text the agent sees for an artifact: in its instructions, when it follows the artifact, and when it reads it with the read_artifact tool. The default is the data as indented JSON, which works but spends tokens on punctuation. A compact rendering usually helps the model more:
class Plan(Artifact):
goal: str = ""
tasks: dict[str, Task] = {}
def render_for_agent(self) -> str:
lines = [f"Goal: {self.goal}"]
lines += [
f"- [{task.status}] {task.title} ({task_id})" for task_id, task in self.tasks.items()
]
return "\n".join(lines)
Include the ids the agent needs to pass to your tools, as this rendering does. The capability clips each rendering in the instructions to max_render_chars (4,000 by default).
The type's JSON Schema, from Pydantic, is given to the agent once, in the description of the create_artifact tool. Field descriptions (Field(description=...)) therefore reach the model.
Summaries of changes¶
Every change carries a one-line summary that people and agents see in change notes, such as "Alice changed plan_1 (plan, v7 → v8): completed Ship v1". The summary comes from the first of these that gives one:
- The command's own
summary, as inplan.edit(fn, summary="moved the launch")or the agent'sedit_text(..., summary=...). - The type's
describe_change(before)hook, which sees the previous state. - A description generated from the patch, such as
add /tasks/task_3f9c...oredited text (1 replacement).
describe_change returns None when it has nothing specific to say, and the next source is used:
from typing import Self
class Plan(Artifact):
tasks: dict[str, Task] = {}
def describe_change(self, before: Self) -> str | None:
done = [
task.title
for task_id, task in self.tasks.items()
if task.status == "done"
and task_id in before.tasks
and before.tasks[task_id].status != "done"
]
return f"completed {', '.join(done)}" if done else None
Annotate before as Self in your override; it is always an instance of the same type.
Markdown documents¶
MarkdownArtifact is a ready-made base for documents: one Markdown text field, rendered to the agent as-is. Subclass it, adding fields if you need them:
Documents are edited with anchored text edits: replace an exact passage that occurs exactly once. This is the edit language models perform most reliably, and it survives other people's edits elsewhere in the document. Versioned.edit_text builds one:
brief = await ws.get(Brief, "brief")
await ws.commit(brief.edit_text("We ship on Friday.", "We ship on Monday."))
The edit is rejected with PatchFailed if the anchor does not occur (the anchor '...' is not found) or occurs more than once (... is ambiguous: it occurs 3 times). An empty anchor is allowed only to write into an empty field. Text edits work on any string field; pass field= to choose one other than text. The agent's generic edit_text tool uses the same edit, so any MarkdownArtifact is editable by the agent without application tools.
How changes are expressed¶
The library defines two patch kinds, and artifact types cannot add their own:
- JSON Patch (RFC 6902) over the artifact's JSON data, for any type.
Versioned.edit(fn)is the usual way to make one: it deep-copies the data, letsfnchange the copy in place, and diffs the result into a patch based on the version you read.fn's return value is ignored. - Text edits (
TextEdits), anchored replacements in one text field, described above.
plan = await ws.get(Plan, "plan_1")
await ws.commit(plan.edit(lambda p: p.add_task("Write the changelog")))
Agents do not write JSON Patch themselves. Give them domain tools (add_task, set_status) that make edits like the one above; see The agent.
Clients that send commands over the wire write patches directly, as in {"kind": "json_patch", "ops": [{"op": "replace", "path": "/goal", "value": "Ship v2"}]}. The thread protocol describes both forms.
Validation¶
Every change is validated: the patch is applied to the current data, and the result must validate against the type. A change that does not is rejected with ValidationFailed, whose errors are Pydantic's errors as JSON, so clients and the agent can see exactly what was wrong. Validators, constraints and coercion on your model all apply. If validation normalizes the data (coercing "3" to 3, say), the revision records a patch that produces exactly what was stored.