agentkit-core is the contract crate for the rest of the workspace.
It should define the smallest stable set of types and traits that every higher-level crate can rely on:
- normalized content and message shapes
- identifiers and envelope types
- tool call and tool result value types
- usage and finish metadata
- provider projection traits
- shared error types
If a type must be understood by both the loop and multiple extension crates, it probably belongs here. If a type controls execution, IO, runtime behavior, or policy, it probably does not.
agentkit-core should not own:
- async runtimes
- the loop driver
- approval flow
- reporter interfaces
- tool execution traits
- shell or filesystem behavior
- MCP transport or process management
- compaction policy
This crate defines data and views, not operational behavior.
agentkit-core should stay runtime-agnostic.
Design constraints:
- no
tokio - no process APIs
- no filesystem APIs
- no networking APIs
- no async trait requirements
Reasonable dependencies:
serdeandserde_jsonthiserror- small utility crates for IDs or timestamps if needed
Optional features are fine, but the base crate should remain lightweight enough for embedded or constrained environments.
The loop, tools, compaction, and reporting need a shared understanding of:
- text and multimodal content
- tool calls
- tool results
- usage
- finish reasons
Those concepts should be first-class core types.
The model must also scale cleanly across provider shapes:
- text-only providers
- voice-only providers
- fully multimodal providers
The goal is that complexity grows roughly linearly with the number of modalities a provider exposes, not combinatorially with every possible provider combination.
Providers should be free to keep their native request and response types.
But they must be able to project those types into agentkit-core views so the rest of the toolkit can operate without provider-specific branching.
The core model should have a small number of first-class fields and an extension bag for opaque provider metadata.
That means:
- normalized fields for things the toolkit actually uses
- namespaced metadata for provider-specific extras
The core should be easy to extend without breaking downstream crates.
That argues for:
- explicit enums for stable categories
- metadata bags for unstable provider details
- newtype IDs instead of raw strings where identity matters
The toolkit will need stable identities across turns and subsystems.
Recommended newtypes:
SessionIdTurnIdMessageIdToolCallIdToolResultIdProviderMessageId
These should be plain data wrappers with display and serialization support.
They matter because reporting, transcript persistence, compaction, and approval flows all need to refer to the same objects safely.
The core model should be item-based rather than tied to old chat-message assumptions.
Recommended shape:
pub struct Item {
pub id: Option<MessageId>,
pub kind: ItemKind,
pub parts: Vec<Part>,
pub metadata: MetadataMap,
/// Token / cost usage for the turn that produced this item.
/// Populated by the loop on assistant items.
pub usage: Option<Usage>,
/// Why the turn that produced this item ended.
/// Populated by the loop on assistant items.
pub finish_reason: Option<FinishReason>,
/// When this item was appended to the transcript (ms since epoch).
/// Stamped by the loop on every appended item.
pub created_at: Option<Timestamp>,
}
pub enum ItemKind {
System,
Developer,
User,
Assistant,
Tool,
Context,
}Why item-based:
- modern providers are converging on content blocks rather than one raw text field
- tools and multimodal content fit more naturally
- compaction can summarize at the item level
- it gives a single durable envelope that can represent text-only, voice-only, and multimodal turns with the same structure
Context is worth keeping distinct from System and Developer because loaded project instructions and skills are not always the same thing as hardcoded product prompts.
The content model should be provider-neutral, inspired by content-block-style APIs, but not coupled to any provider SDK or response schema.
More importantly, it should be resilient to provider differences.
That means:
- the durable transcript format should always be
Item { parts: Vec<Part> } - each part should represent one coherent unit of content
- provider-specific differences should be allowed to survive through metadata and extension parts
- the model should not become exponentially more complex as more modalities are supported
V1 should ship with a comprehensive multimodal part set.
Reason:
agentkitshould feel like a compelling alternative to existing agent SDKs- users should not need to immediately fall back to
Customfor common modalities - text-only, voice-only, and multimodal providers should all map naturally on day one
Recommended v1 shape:
pub enum Part {
Text(TextPart),
Media(MediaPart),
File(FilePart),
Structured(StructuredPart),
Reasoning(ReasoningPart),
ToolCall(ToolCallPart),
ToolResult(ToolResultPart),
Custom(CustomPart),
}Recommended part semantics:
Text: plain user-visible textMedia: modality-tagged media such as audio, image, or videoFile: file reference or uploaded artifact descriptorStructured: typed structured payload that should remain machine-readableReasoning: provider-supplied reasoning summary or reasoning block when availableToolCall: a request by the model to invoke a named tool with structured inputToolResult: structured output returned from a toolCustom: escape hatch for provider-specific part types that do not map cleanly yet
The key rules are:
ToolCallandToolResultare content parts, not side channels- multimodal content is first-class, not hidden in metadata
- provider-specific differences may leak across boundaries through
Customand namespaced metadata without breaking the rest of the model
Recommended supporting shapes:
pub struct MediaPart {
pub modality: Modality,
pub mime_type: String,
pub data: DataRef,
pub metadata: MetadataMap,
}
pub enum Modality {
Audio,
Image,
Video,
Binary,
}
pub enum DataRef {
InlineText(String),
InlineBytes(Vec<u8>),
Uri(String),
Handle(ArtifactId),
}This is what makes a voice-only provider and a text-only provider equally natural to represent:
- text-only provider:
ItemwithTextparts - voice-only provider:
ItemwithMedia { modality: Audio, ... } - multimodal provider:
Itemwith multiple parts
That gives the desired linear complexity increase as modalities are added.
To make this comprehensive in practice, the supporting part types should cover the common cases directly rather than forcing everything through opaque blobs.
Recommended v1 part coverage:
TextPart- plain text
- optional formatting or segmentation metadata
MediaPart- audio input/output
- image input/output
- video references
- generic binary media fallback
FilePart- uploaded files
- generated artifacts
- local or remote file references
StructuredPart- arbitrary JSON payloads
- typed structured model outputs
- schema-carrying machine-readable data
ReasoningPart- reasoning summaries
- redacted/private reasoning references where available
ToolCallPart- model-issued tool invocation requests
ToolResultPart- normalized tool results
CustomPart- provider-specific escape hatch for genuinely unmapped content
The intended v1 standard is:
- common modalities should map to first-class parts
CustomPartshould be rare, not the normal path
The core should distinguish between durable content and streamed deltas.
Recommended split:
Partfor completed transcript contentDeltafor streamed output fragments and part construction events
Recommended v1 shape:
pub enum Delta {
BeginPart { part_id: PartId, kind: PartKind },
AppendText { part_id: PartId, chunk: String },
AppendBytes { part_id: PartId, chunk: Vec<u8> },
ReplaceStructured {
part_id: PartId,
value: serde_json::Value,
},
SetMetadata { part_id: PartId, metadata: MetadataMap },
CommitPart { part: Part },
}This model is more resilient than making deltas mirror final parts one-for-one.
It reflects what usually happens in practice:
- during the turn, providers stream partial updates
- the loop or adapter folds those updates into finalized parts
- after the turn, the durable transcript is stored as
Itemvalues with committed parts - on the next turn, transcript items are reused, not raw deltas
So the right rule is:
- deltas are transient and incremental
- parts are durable and replayable
Delta belongs in core because adapters, the loop, and reporters all need to agree on what a streamed update means and how it folds into a final transcript shape.
For v1, Delta should also be comprehensive enough for common streaming behaviors:
- incremental text streaming
- incremental audio or binary chunk streaming when a provider supports it
- staged tool call construction if needed
- final structured payload replacement or commit
- metadata updates during the turn
Recommended v1 delta coverage:
BeginPartAppendTextAppendBytesReplaceStructuredSetMetadataCommitPart
This keeps the delta model generic without forcing a bespoke delta variant for every final part type.
Even if tool execution traits live in agentkit-tools-core, the value types for tool interaction should live in core.
Recommended shape:
pub struct ToolCallPart {
pub id: ToolCallId,
pub name: String,
pub input: serde_json::Value,
pub metadata: MetadataMap,
}
pub struct ToolResultPart {
pub call_id: ToolCallId,
pub output: ToolOutput,
pub is_error: bool,
pub metadata: MetadataMap,
}Recommended ToolOutput shape:
- text
- structured JSON
- file references
- mixed content parts
This is important because different providers want tool results serialized differently, but agentkit should retain a richer normalized form before adapter-specific re-encoding.
These belong in core because the loop, reporters, and cost accounting all depend on them.
Recommended types:
UsageTokenUsageCostUsageFinishReason
FinishReason should be normalized to a small stable set:
CompletedToolCallMaxTokensCancelledBlockedErrorOther(String)
Providers can map their native stop reasons into this enum and keep the original value in metadata if needed.
Core types should consistently support opaque extension metadata.
Recommended shape:
pub type MetadataMap = BTreeMap<String, serde_json::Value>;Recommended convention:
- provider-specific keys should be namespaced, for example
openai.*oranthropic.* agentkitcrates should not depend on provider-specific metadata for correctness
This is the cleanest way to preserve useful details without making them core semantics.
This is the most important part of the design.
You suggested that providers should be able to encode their own message types and expose traits for extracting what agentkit needs. I think that is right, and agentkit-core is where those traits should live.
The goal is:
- provider adapters keep native types internally
- loop and reporters consume normalized views
- conversion stays explicit and testable
- provider-specific data can survive normalization through metadata or custom parts when needed
There are two viable patterns.
The adapter converts provider-native data into concrete agentkit-core types immediately.
Example:
fn normalize_message(native: ProviderMessage) -> ItemPros:
- simple for downstream crates
- easy to serialize, store, and test
Cons:
- may allocate more
- may lose some structure unless metadata is preserved carefully
The adapter keeps native types and exposes accessor methods that return normalized borrows.
Pros:
- keeps provider ownership and layout flexible
- can reduce copying
Cons:
- more lifetime complexity
- harder to store and pass around dynamically
v1 lands on Pattern A across the workspace: the canonical normalized model is the concrete Item struct, and every adapter materialises owned values at its boundaries.
agentkit-coredefines concrete normalized types as the canonical interchange modelagentkit-loopnormalises to owned core types at its boundaries- adapters that want zero-copy internals keep that inside the adapter and convert on egress
That gives efficient adapters a path to stay zero-copy internally, while keeping the rest of the system simple.
The normalized model should therefore be:
- open enough to represent cross-provider differences without pain
- constrained enough that the rest of the stack can build stable behavior on top of it
That is the reason for the Item plus Part plus Delta approach.
For v1, the design target should be:
- comprehensive first-class support for common multimodal content
CustomPartreserved for genuinely unusual provider-specific cases- enough delta expressiveness to support both classic SSE text streaming and richer multimodal streams
The core error model should be small and composable.
Recommended types:
AgentError: top-level shared error enumNormalizeError: conversion/projection failuresProtocolError: invalid provider or tool interaction states
Avoid turning AgentError into a kitchen sink. Domain-specific crates should wrap or convert into it only where cross-crate handling matters.
These should live elsewhere:
ModelAdapterandModelSession:agentkit-loopor a closely related crate, because they are operational interfacesReporter:agentkit-reportingToolexecution traits and registry:agentkit-tools-coreApprovalRequest:agentkit-loop- permission policies:
agentkit-tools-coreor a dedicated policy crate later
The core crate should not become a dumping ground for “shared” concepts that are only shared because nothing else has been designed yet.
agentkit-core/
src/
lib.rs
ids.rs
item.rs
content.rs
delta.rs
tool.rs
usage.rs
finish.rs
metadata.rs
error.rs
Module intent:
ids.rs: newtype identifiersitem.rs:ItemandItemKindcontent.rs: durableParttypes and multimodal data referencesdelta.rs: streamingDeltatypes and foldable part construction eventstool.rs: tool call/result value typesusage.rs: tokens, cost, usage snapshotsfinish.rs: normalized finish reasonsmetadata.rs: metadata bag and conventionserror.rs: shared errors
The agentkit-core bar should be higher than other crates because churn here spreads everywhere.
Before stabilizing the first public version, we should prove:
- one fake stateless provider can map into these types cleanly
- one fake stateful provider can map into these types cleanly
- shell and filesystem tool results fit without awkward escaping
- reporters can stream deltas without provider-specific branching
- compaction can summarize
Items without needing hidden provider data
If any of those fail, the core model is missing something important.
The first pass of agentkit-core should be intentionally small.
Implement first:
- IDs
ItemandItemKindPartDeltaToolCallPartToolResultPartUsageFinishReasonMetadataMap- projection traits
- shared errors
Do not implement first:
- deeply nested multimodal special cases
- elaborate serialization formats
- approval concepts
- runtime traits
But do implement in the first public release:
- a comprehensive multimodal
Partset - a comprehensive but generic
Deltaset - examples proving text-only, voice-only, and multimodal provider mappings
The fastest way to validate core is to use it immediately from a fake provider and a fake reporter. If those two consumers feel natural, the crate boundary is probably right.