Build once, invoke by Code or AI.
A schema-enforced module standard for the AI-Perceivable era.
apcore is an AI-Perceivable module standard that makes every interface naturally perceivable and understandable by AI through enforced Schema definitions and behavioral annotations. It provides strict type safety, access control, middleware pipelines, and built-in observability — enabling you to define modules with structured input/output schemas that are easily consumed by both code and AI.
- Schema-driven modules — Define input/output contracts using
schemars-derived types with automatic validation - Execution Pipeline — Context creation, call chain guard, ACL enforcement, approval gate, middleware before, validation, execution, output validation, middleware after, and return — with step metadata (
match_modules,ignore_errors,pure,timeout_ms) and YAML pipeline configuration Moduletrait — Implement theModuletrait to create fully schema-aware modules- YAML bindings — Register modules declaratively without modifying source code
- Access control (ACL) — Pattern-based, first-match-wins rules with wildcard support
- Middleware system — Composable before/after hooks with error recovery
- Observability — Tracing (spans), metrics collection, and structured context logging
- Async support — Built on
tokiofor seamless async module execution - Safety guards — Call depth limits, circular call detection, frequency throttling
- Approval system — Pluggable approval gate with async handlers, Phase B resume, and audit events
- Behavioral annotations — Declare module traits (readonly, destructive, idempotent, cacheable, paginated, streaming) for AI-aware orchestration
- W3C Trace Context —
traceparentheader injection/extraction for distributed tracing interop
The Rust SDK tracks the apcore protocol spec but currently omits two language-specific extensions that ship in the Python and TypeScript SDKs:
| Feature | Python | TypeScript | Rust |
|---|---|---|---|
AsyncTaskManager (background task execution) |
Yes | Yes | Not yet |
ExtensionManager / ExtensionPoint (plugin registry) |
Yes | Yes | Not yet |
Both will be reintroduced when their Rust implementations are wired into
Executor with real concurrency and plugin loading. For now, code that
needs these features should stay in Python or TypeScript.
Core
| Type | Description |
|---|---|
APCore |
High-level client — register modules, call, stream, validate |
Registry |
Module storage — discover, register, get, list, watch |
Executor |
Execution engine — call with middleware pipeline, ACL, approval |
Context |
Request context — trace ID, identity, call chain, cancel token |
Config |
Configuration — from_defaults with env overrides, load YAML/JSON, get/set dot-path, validate, reload |
Identity |
Caller identity — id, type, roles, attributes |
Module |
Core trait for implementing schema-aware modules |
Access Control & Approval
| Type | Description |
|---|---|
ACL |
Access control — rule-based caller/target authorization |
ApprovalHandler |
Pluggable approval gate trait |
AlwaysDenyHandler / AutoApproveHandler |
Built-in approval handlers |
Middleware
| Type | Description |
|---|---|
Middleware |
Pipeline hooks — before/after/on_error interception |
BeforeMiddleware / AfterMiddleware |
Single-phase middleware adapters |
ObsLoggingMiddleware |
Structured logging middleware |
RetryMiddleware |
Automatic retry with backoff |
ErrorHistoryMiddleware |
Records errors into ErrorHistory |
PlatformNotifyMiddleware |
Emits events on error rate/latency spikes |
Schema
| Type | Description |
|---|---|
SchemaLoader |
Load schemas from YAML or native types |
SchemaValidator |
Validate data against schemas |
SchemaExporter |
Export schemas for MCP, OpenAI, Anthropic, generic |
RefResolver |
Resolve $ref references in JSON Schema |
Observability
| Type | Description |
|---|---|
TracingMiddleware |
Distributed tracing with span export |
MetricsMiddleware / MetricsCollector |
Call count, latency, error rate metrics |
ContextLogger |
Context-aware structured logging |
ErrorHistory |
Ring buffer of recent errors with deduplication |
UsageCollector |
Per-module usage statistics and trends |
Events & Utilities
| Type | Description |
|---|---|
EventEmitter |
Event system — subscribe, unsubscribe, emit, emit_filtered, flush |
WebhookSubscriber |
Built-in event subscriber |
CancelToken |
Cooperative cancellation token |
BindingLoader |
Load modules from YAML binding files |
See Cross-Language Feature Parity for a note on
ExtensionManagerandAsyncTaskManager, which exist in Python/TypeScript but are not yet available in the Rust SDK.
For full documentation, including Quick Start guides for Python and Rust, visit: https://aiperceivable.github.io/apcore/getting-started.html
- Rust >= 1.75
- Tokio async runtime
Add to your Cargo.toml:
[dependencies]
apcore = "0.18"
tokio = { version = "1", features = ["full"] }
serde_json = "1"use apcore::APCore;
use apcore::module::Module;
use apcore::context::Context;
use serde_json::{json, Value};
struct AddModule;
#[async_trait::async_trait]
impl Module for AddModule {
fn description(&self) -> &str { "Add two integers" }
fn input_schema(&self) -> Value {
json!({"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, "required": ["a", "b"]})
}
fn output_schema(&self) -> Value {
json!({"type": "object", "properties": {"result": {"type": "integer"}}})
}
async fn execute(
&self,
input: Value,
_ctx: &Context<Value>,
) -> Result<Value, apcore::errors::ModuleError> {
let a = input["a"].as_i64().unwrap_or(0);
let b = input["b"].as_i64().unwrap_or(0);
Ok(json!({ "result": a + b }))
}
}
#[tokio::main]
async fn main() {
let mut client = APCore::new();
client.register("math.add", Box::new(AddModule)).unwrap();
let result = client
.call("math.add", json!({"a": 10, "b": 5}), None, None)
.await
.unwrap();
println!("{}", result); // {"result": 15}
}use apcore::APCore;
#[tokio::main]
async fn main() {
// Load directly from file path
let client = APCore::from_path("apcore.yaml").unwrap();
// Or load and modify config before constructing
// let config = Config::from_yaml_file(Path::new("apcore.yaml")).unwrap();
// let client = APCore::with_config(config);
}use apcore::module::{Module, ModuleAnnotations};
use apcore::context::Context;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Serialize, Deserialize)]
struct GetUserInput {
user_id: String,
}
#[derive(Serialize, Deserialize)]
struct GetUserOutput {
id: String,
name: String,
email: String,
}
struct GetUserModule;
#[async_trait::async_trait]
impl Module for GetUserModule {
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"user_id": { "type": "string" }
},
"required": ["user_id"]
})
}
fn output_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"email": { "type": "string" }
}
})
}
fn description(&self) -> &str { "Get user details by ID" }
// Annotations (readonly, idempotent, etc.) are set on
// ModuleDescriptor when registering the module with the registry.
async fn execute(
&self,
input: Value,
_ctx: &Context<Value>,
) -> Result<Value, apcore::errors::ModuleError> {
let req: GetUserInput = serde_json::from_value(input)?;
let user = match req.user_id.as_str() {
"user-1" => GetUserOutput { id: "user-1".into(), name: "Alice".into(), email: "[email protected]".into() },
"user-2" => GetUserOutput { id: "user-2".into(), name: "Bob".into(), email: "[email protected]".into() },
id => GetUserOutput { id: id.into(), name: "Unknown".into(), email: "[email protected]".into() },
};
Ok(serde_json::to_value(user)?)
}
}use apcore::observability::{ContextLogger, ObsLoggingMiddleware};
client.use_middleware(Box::new(ObsLoggingMiddleware::new(ContextLogger::new("app"))));
// TracingMiddleware requires a SpanExporter — see observability docsuse apcore::acl::{ACL, ACLRule};
let acl = ACL::new(vec![
ACLRule { callers: vec!["admin.*".into()], targets: vec!["*".into()], effect: "allow".into(), description: Some("Admins can call anything".into()), conditions: None },
ACLRule { callers: vec!["*".into()], targets: vec!["admin.*".into()], effect: "deny".into(), description: Some("Others cannot call admin modules".into()), conditions: None },
], "deny", None);Register modules without touching Rust source — define a binding.yaml:
bindings:
- module_id: "utils.format_date"
target: "format_date::format_date_string"
description: "Format a date string into a specified format"
tags: ["utility", "date"]
version: "1.0.0"
input_schema:
type: object
properties:
date_string: { type: string }
output_format: { type: string }
required: [date_string, output_format]
output_schema:
type: object
properties:
formatted: { type: string }
required: [formatted]Load it at runtime:
use apcore::bindings::BindingLoader;
let loader = BindingLoader::new();
loader.load_from_file(std::path::Path::new("binding.yaml")).unwrap();The Python and TypeScript SDKs support *_meta.yaml sidecar files that override
code-defined module annotations at load time (PROTOCOL_SPEC.md §4.13: field-level
merge, YAML wins over code). The Rust SDK does not implement this feature.
This is a deliberate design choice:
- Spec §4.13 is conditional: it mandates field-level merge only "when both YAML metadata file and code define Annotations". If the SDK never loads YAML metadata files, the rule is never triggered.
- Rust favours explicit configuration: annotations are declared via
ModuleAnnotationsin code and are type-checked at compile time. YAML-based override introduces implicit, late-bound behavior that conflicts with Rust's "explicit > implicit" philosophy. - No user demand: as of v0.18.0 there are zero issues or RFCs requesting YAML annotation overlays for the Rust SDK.
If you need runtime-configurable annotations (e.g., ops teams toggling readonly or
requires_approval without recompiling), you can load a YAML/JSON file yourself and
construct ModuleAnnotations via serde:
let yaml: serde_json::Value = serde_yaml_ng::from_reader(file)?;
let annotations: ModuleAnnotations = serde_json::from_value(yaml)?;
let descriptor = ModuleDescriptor { annotations, ..default_descriptor };
registry.register("my.module", module, descriptor)?;The examples/ directory contains runnable demos. Run any example with:
cargo run --example simple_client
cargo run --example greet
cargo run --example get_user
cargo run --example send_email
cargo run --example cancel_tokenDefines two modules (AddModule, GreetModule), builds an Identity + Context, and calls them directly without a registry.
use apcore::context::{Context, Identity};
use apcore::errors::ModuleError;
use apcore::module::Module;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
struct AddModule;
#[async_trait]
impl Module for AddModule {
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"a": { "type": "integer" },
"b": { "type": "integer" }
},
"required": ["a", "b"]
})
}
fn output_schema(&self) -> Value {
json!({ "type": "object", "properties": { "result": { "type": "integer" } } })
}
fn description(&self) -> &str { "Add two integers" }
async fn execute(&self, input: Value, _ctx: &Context<Value>) -> Result<Value, ModuleError> {
let a = input["a"].as_i64().unwrap_or(0);
let b = input["b"].as_i64().unwrap_or(0);
Ok(json!({ "result": a + b }))
}
}
#[tokio::main]
async fn main() {
let identity = Identity::new(
"user-1".to_string(),
"user".to_string(),
vec!["user".to_string()],
HashMap::new(),
);
let ctx: Context<Value> = Context::new(identity);
let module = AddModule;
let result = module.execute(json!({"a": 10, "b": 5}), &ctx).await.unwrap();
println!("{result}"); // {"result":15}
}Uses #[serde(default)] for optional fields and shows schema introspection and validation error handling.
use apcore::context::{Context, Identity};
use apcore::errors::ModuleError;
use apcore::module::Module;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize)]
struct GreetInput {
name: String,
#[serde(default = "default_greeting")]
greeting: String,
}
fn default_greeting() -> String { "Hello".to_string() }
#[derive(Debug, Serialize, Deserialize)]
struct GreetOutput { message: String }
struct GreetModule;
#[async_trait]
impl Module for GreetModule {
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Name of the person to greet" },
"greeting": { "type": "string", "description": "Custom greeting prefix", "default": "Hello" }
},
"required": ["name"]
})
}
fn output_schema(&self) -> Value {
json!({ "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] })
}
fn description(&self) -> &str { "Greet a user by name" }
async fn execute(&self, input: Value, _ctx: &Context<Value>) -> Result<Value, ModuleError> {
let req: GreetInput = serde_json::from_value(input)
.map_err(|e| ModuleError::new(apcore::errors::ErrorCode::GeneralInvalidInput, e.to_string()))?;
Ok(serde_json::to_value(GreetOutput { message: format!("{}, {}!", req.greeting, req.name) }).unwrap())
}
}
#[tokio::main]
async fn main() {
let identity = Identity::new("agent-1".to_string(), "agent".to_string(), vec![], HashMap::new());
let ctx: Context<Value> = Context::new(identity);
let module = GreetModule;
let out = module.execute(json!({"name": "Alice", "greeting": "Good morning"}), &ctx).await.unwrap();
println!("{out}"); // {"message":"Good morning, Alice!"}
let out = module.execute(json!({"name": "Bob"}), &ctx).await.unwrap();
println!("{out}"); // {"message":"Hello, Bob!"} ← default greeting applied
// Schema introspection
println!("{}", serde_json::to_string_pretty(&module.input_schema()).unwrap());
// Missing required field → validation error
let err = module.execute(json!({"greeting": "Hi"}), &ctx).await.unwrap_err();
println!("Error: {err}");
}Demonstrates behavioral annotations (readonly, idempotent, cacheable), typed input/output schemas, and looking up records by ID.
use apcore::module::{Module, ModuleAnnotations};
// ...
fn get_user_annotations() -> ModuleAnnotations {
ModuleAnnotations {
readonly: true,
idempotent: true,
cacheable: true,
cache_ttl: 60,
..Default::default()
}
}user-1: {"email":"[email protected]","id":"user-1","name":"Alice"}
user-2: {"email":"[email protected]","id":"user-2","name":"Bob"}
user-999: {"email":"[email protected]","id":"user-999","name":"Unknown"}
Shows x-sensitive: true on schema fields (for log redaction), ModuleAnnotations with metadata, and behavioral annotation for destructive operations.
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"to": { "type": "string" },
"subject": { "type": "string" },
"body": { "type": "string" },
"api_key": { "type": "string", "x-sensitive": true } // redacted in logs
},
"required": ["to", "subject", "body", "api_key"]
})
}fn send_email_annotations() -> ModuleAnnotations {
ModuleAnnotations {
destructive: true,
requires_approval: true,
..Default::default()
}
}
fn send_email_examples() -> Vec<ModuleExample> {
vec![ModuleExample {
title: "Send a welcome email".to_string(),
inputs: json!({ "to": "[email protected]", "subject": "Welcome!", "body": "...", "api_key": "sk-xxx" }),
output: json!({ "status": "sent", "message_id": "msg-12345" }),
description: None,
}]
}CancelToken is a cloneable, shared cancellation signal. Modules poll token.is_cancelled() between steps to stop early.
use apcore::cancel::CancelToken;
// Attach a token to the context
let mut ctx: Context<Value> = Context::new(identity);
let token = CancelToken::new();
ctx.cancel_token = Some(token.clone());
// Cancel from another task after 80 ms
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(80)).await;
token.cancel();
});
// Module checks the token between steps
async fn execute(&self, input: Value, ctx: &Context<Value>) -> Result<Value, ModuleError> {
for i in 0..steps {
if let Some(t) = &ctx.cancel_token {
if t.is_cancelled() {
return Err(ModuleError::new(ErrorCode::ExecutionCancelled, format!("cancelled at step {i}")));
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(json!({ "completed_steps": steps }))
}=== Run 1: normal execution ===
[SlowModule] Executing step 0...
[SlowModule] Executing step 1...
[SlowModule] Executing step 2...
Result: {"completed_steps":3}
=== Run 2: cancelled mid-flight ===
[SlowModule] Executing step 0...
[SlowModule] Executing step 1...
[main] Sending cancel signal…
[SlowModule] Cancelled at step 2
Error (expected): Execution cancelled after 2 steps
Run all tests:
cargo testRun a specific test file:
cargo test --test test_cancel
cargo test --test test_errorsRun a specific test by name:
cargo test test_cancel_tokenRun with output visible:
cargo test -- --nocaptureInstall Rust via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shgit clone https://github.com/aiperceivable/apcore-rust.git
cd apcore-rust
cargo buildcargo testcargo test -- --nocapturecargo test test_cancel_tokencargo fmt # auto-format code
cargo clippy # lintcargo doc --opencargo checkApache-2.0
- Documentation: https://aiperceivable.github.io/apcore/
- Website: aiperceivable.com
- GitHub: aiperceivable/apcore-rust
- crates.io: apcore
- Issues: GitHub Issues
- Discussions: GitHub Discussions