Build a Trusted Local Module Use this guide to compile one reviewed, first-party capability into the same process as aipd. You will pair a context-aware action handler with an exact manifest contribution. You will then register its factory before startup and verify admission before the listener becomes available. A trusted local module is a deployment composition boundary. It is not a runtime plugin system. Adding, removing, or changing a module requires a new application build and deployment. The module shares the daemon's process, runtime stores, resource limits, and failure domain. The examples on this page target source revision 97be86e9efedf07ecf1783b03800f683f107fb04. This authoring pass checked the source and snippet syntax without building the workspace or starting a service. Choose the local boundary deliberately Use a local module only when all of these statements are true: • the implementation is reviewed and operated as first-party daemon code; • the module can use the daemon's release, startup, and rollback lifecycle; • sharing one process and runtime-store handles is an accepted trust boundary; • the complete contribution can be prepared within a bounded startup timeout; • one invalid manifest, handler set, or route claim should fail startup. Use the remote connector fleet when an implementation needs independent rollout, scaling, isolation, provider-specific ownership, or long-tail catalog growth. Local placement does not make an implementation safer, qualified, or production-ready. It only changes process composition and dispatch placement. Prerequisites You need: • Rust 1.88 or newer and edition 2024 support; • access to the reviewed source revision or an approved immutable mirror; • an application that owns AipDaemonDeployment construction; • a reviewed capability contract and JSON Schemas for its input and output; • a decision that the module is required or optional during preparation; • deployment-owned identity, approval, storage, and transport configuration. This guide uses a pure label normalizer. It contacts no provider, stores no secret, and performs no external mutation. Production trust and storage setup remain separate deployment concerns. 1. Pin the direct dependencies Create a binary project and pin every AIP crate to the same source revision: [package] name = "aipd-local-module" version = "0.1.0" edition = "2024" rust-version = "1.88" [dependencies] aip-core = { git = "https://github.com/getaip/core", rev = "97be86e9efedf07ecf1783b03800f683f107fb04" } aip-discovery = { git = "https://github.com/getaip/core", rev = "97be86e9efedf07ecf1783b03800f683f107fb04" } aip-runtime = { git = "https://github.com/getaip/core", rev = "97be86e9efedf07ecf1783b03800f683f107fb04" } aipd = { git = "https://github.com/getaip/core", rev = "97be86e9efedf07ecf1783b03800f683f107fb04" } async-trait = "0.1" serde = { version = "1", features = ["derive"] } serdejson = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } Every AIP workspace package has publish = false at the reviewed revision. Do not replace these entries with an assumed registry version. An approved vendored checkout may use path dependencies, but its revision must be recorded outside Cargo.lock through the deployment or SBOM process. 2. Implement the handler and module factory Create src/main.rs with one complete module. The handler rejects the context-free compatibility method and accepts only the runtime-built ActionExecutionContext path: use aipcore::{ Action, ActionResult, ActionResultStatus, Binding, Capability, CapabilityContract, CapabilityId, CapabilityKind, CompensationContract, CompensationMode, DataContract, DataSensitivity, ExecutionContract, ExpectedCompletionMode, IdempotencyCollisionBehavior, IdempotencyContract, IdempotencyKeyScope, IdempotencyRequirement, Manifest, MessagePart, Principal, PrincipalKind, ProfileId, RetrySafety, RiskLevel, ServiceLevelContract, SideEffect, Stability, }; use aipdiscovery::CapabilityImplementationSupport; use aipruntime::{ ActionExecutionContext, ActionHandler, RuntimeError, RuntimeResult, }; use aipd::{ AipDaemon, AipDaemonConfig, AipDaemonDeployment, DaemonModuleError, DaemonModuleFactory, DaemonServices, LocalModuleDescriptor, PreparedDaemonModule, }; use asynctrait::asynctrait; use serde::Deserialize; use serdejson::json; use std::{collections::BTreeSet, error::Error, sync::Arc}; const MODULEID: &str = "label-normalizer"; const CAPABILITYID: &str = "cap:example:label.normalize"; const NATIVEHTTPPROFILE: &str = "aip.native.http.v1"; #[derive(Clone)] struct LabelNormalizer; #[derive(Deserialize)] #[serde(denyunknownfields)] struct NormalizeInput { label: String, } #[asynctrait] impl ActionHandler for LabelNormalizer { fn implementationsupport(&self) -> CapabilityImplementationSupport { CapabilityImplementationSupport { invocation: true, retry: true, ..CapabilityImplementationSupport::default() } } async fn handle(&self, action: Action) -> RuntimeResult { Err(RuntimeError::Authorization( "label normalizer requires trusted execution context".toowned(), )) } async fn handlewithcontext( &self, action: Action, context: ActionExecutionContext, ) -> RuntimeResult { context .actor .validate(&BTreeSet::new()) .maperr(|error| RuntimeError::Authorization(error.tostring()))?; let input: NormalizeInput = serdejson::fromvalue(action.input.clone()) .maperr(|error| RuntimeError::Handler(error.tostring()))?; let normalized = input.label.splitwhitespace().collect::>().join(" "); if normalized.isempty() { return Err(RuntimeError::Handler( "label must contain a non-whitespace character".toowned(), )); } Ok(ActionResult { actionid: action.id, status: ActionResultStatus::Completed, output: Some(json!({ "normalized": normalized })), message: vec![MessagePart::text("label normalized")], memoryupdate: None, usage: None, receipt: None, error: None, }) } } fn normalizecapability() -> Capability { Capability { id: CapabilityId::trusted(CAPABILITYID), name: "Normalize a label".toowned(), kind: CapabilityKind::Tool, inputschema: json!({ "type": "object", "additionalProperties": false, "required": ["label"], "properties": { "label": { "type": "string", "minLength": 1, "maxLength": 256 } } }), outputschema: Some(json!({ "type": "object", "additionalProperties": false, "required": ["normalized"], "properties": { "normalized": { "type": "string", "minLength": 1, "maxLength": 256 } } })), description: Some("Collapses whitespace in one label.".toowned()), risk: Some(RiskLevel::Low), stability: Some(Stability::Stable), cost: None, auth: None, bindings: vec![Binding { profile: ProfileId::from(NATIVEHTTPPROFILE), metadata: [ ("method".toowned(), json!("POST")), ("path".toowned(), json!("/aip/v1/messages")), ( "messagetype".toowned(), json!("aip.core.v1.action"), ), ] .intoiter() .collect(), }], requireshumanapproval: Some(false), contract: Some(CapabilityContract { sideeffects: vec![SideEffect::Read], idempotency: IdempotencyContract { requirement: IdempotencyRequirement::Optional, collisionbehavior: IdempotencyCollisionBehavior::ReturnOriginalResult, keyscope: IdempotencyKeyScope::Capability, ttlms: None, }, execution: ExecutionContract { supportssync: true, supportsasync: false, supportsstreaming: false, supportscancel: false, supportsretry: true, expectedcompletion: ExpectedCompletionMode::Sync, retrysafety: RetrySafety::Safe, }, data: DataContract { sensitivity: DataSensitivity::Internal, containspii: false, redactionrequired: false, residency: None, retention: None, }, credentials: None, approval: None, sla: Some(ServiceLevelContract { expectedlatencyms: Some(10), timeoutms: Some(1000), asyncexpected: false, maxqueuedelayms: Some(100), availabilitytarget: None, }), transaction: None, compensation: Some(CompensationContract { mode: CompensationMode::NotRequired, compensationcapabilityid: None, compensationwindowms: None, requiresapproval: false, }), }), } } #[derive(Clone)] struct LabelNormalizerModule; #[asynctrait] impl DaemonModuleFactory for LabelNormalizerModule { fn descriptor(&self) -> LocalModuleDescriptor { LocalModuleDescriptor::requiredstatic(MODULEID) } async fn prepare( &self, services: &DaemonServices, ) -> Result { let capability = normalizecapability(); let manifest = Manifest { manifestversion: "aip-manifest/v1".toowned(), agent: Principal::new(services.serviceid().clone(), PrincipalKind::Agent), capabilities: vec![capability.clone()], profiles: vec![ProfileId::from(NATIVEHTTPPROFILE)], resources: Vec::new(), channels: Vec::new(), security: None, governance: None, limits: None, compatibility: None, extensions: None, }; PreparedDaemonModule::new(self.descriptor(), manifest) .withhandler(capability, Arc::new(LabelNormalizer)) } } #[tokio::main] async fn main() -> Result> { let config = AipDaemonConfig::default(); let bind = config.bind; let deployment = AipDaemonDeployment::default() .withmodulefactory(LabelNormalizerModule); let daemon = AipDaemon::newwithdeployment(config, deployment).await?; let admitted = daemon .manifest() .capabilities .iter() .any(|capability| capability.id.asstr() == CAPABILITYID); if !admitted { return Err(std::io::Error::other("local capability was not admitted").into()); } daemon.serve(bind).await?; Ok(()) } The descriptor is static and performs no I/O. prepare receives only the daemon service principal and cloned runtime-store handles. It does not receive the gateway router, active-action map, callback dispatcher, or policy engine. The handler's implementation support must match its capability contract. Invocation and safe retry are enabled here. Cancellation, streaming, transactions, reconciliation, compensation, approval, and credential resolution remain disabled. 3. Keep the manifest and handler set exact PreparedDaemonModule::withhandler records a handler by capability ID. The daemon later compares the complete executable capability set with the complete handler set. Every non-resource capability needs exactly one handler, and a resource capability cannot receive an action handler. The module manifest contributes capabilities, profiles, resources, and channels to the daemon manifest. The daemon retains its own security, governance, limits, and principal configuration. It also records a bounded module summary under the combined manifest's connector extensions. Do not treat a module manifest as a way to override daemon security. Configure identity resolvers, approval authority, storage, callback policy, and transports in the deployment that owns AipDaemonConfig and AipDaemonDeployment. 4. Register the factory before daemon construction withmodulefactory adds the concrete factory to the frozen deployment composition. AipDaemon::newwithdeployment prepares and admits every factory before it returns a daemon. serve binds the listener only after that constructor has succeeded. The example uses default daemon security solely to keep module composition visible. Default native AIP requests still require signed envelopes and trusted signer configuration. Add production trust and durable storage through the owning deployment; do not weaken authentication to test the module. Generate the lockfile, compile the application, and start it in the intended test environment: cargo generate-lockfile cargo check --locked cargo run --locked Treat these commands as instructions for the consumer project. They were not executed during this source-only documentation pass. 5. Verify admission and readiness In another terminal, query the native manifest and readiness endpoint: curl -fsS http://127.0.0.1:8080/aip/v1/manifest \ | jq -e '.capabilities[] | select(.id == "cap:example:label.normalize")' curl -fsS http://127.0.0.1:8080/ready \ | jq -e '.modules["label-normalizer"] == { "required": true, "ready": true, "detail": "module prepared and admitted" }' Both commands must exit with status 0. A ready: true module status means that preparation and admission succeeded. It does not prove provider health, external side effects, durability, conformance, qualification, or production readiness. If the module registers a readiness connector, gateway readiness evaluates that connector separately. Module admission status is not a substitute for its readiness result. 6. Understand the startup decision The daemon applies this sequence before publishing the combined manifest: 1. reject more than 64 factories; 2. validate every static ID and timeout; 3. sort factories by module ID and reject duplicate IDs; 4. run each prepare future under its declared timeout; 5. require the returned descriptor to equal the static descriptor; 6. validate manifest size and exact handler coverage; 7. reject capability, connector, and HTTP-route ownership conflicts; 8. merge accepted contributions and construct the gateway atomically; 9. register readiness connectors and delegation routers; 10. bind the HTTP listener only when application code calls serve. Required and optional descriptors differ only at step 4. An error or timeout from prepare stops startup for a required module. The same result skips an optional module and records required: false, ready: false in /ready. Optional does not suppress an invalid ID, invalid timeout, changed descriptor, invalid manifest, handler mismatch, or ownership conflict. Those errors still stop daemon construction because the composition cannot be admitted safely. 7. Stay within the module limits The reviewed implementation applies these hard bounds: | Boundary | Limit | Design consequence | | Modules per daemon | 64 | Prefer the remote fleet for a growing connector catalog | | Module ID | 1–64 characters | Use lower-case ASCII letters, digits, ., , or - | | Preparation timeout | 1–300,000 ms | Default is 30,000 ms; keep startup work bounded | | Serialized manifest | 4 MiB per module | Keep product catalogs and large generated data outside the contribution | | Executable handlers | 4,096 per module | Every callable capability still needs one exact handler | | HTTP route claims | 256 per mount | Declare each method and path for conflict detection | | HTTP path | 512 characters | Keep every module route below /connectors/ | | Error detail created with DaemonModuleError::new | 1,024 characters | Never place credentials or provider payloads in the message | DaemonModuleError::new truncates its message, but truncation is not redaction. Return a stable error code and a bounded operational description without secret material. 8. Add optional contributions only when owned A prepared module can contribute more than action handlers: | Contribution | API | Ownership requirement | | Runtime-store access during preparation | DaemonServices::runtimestores | Use only stores owned by the daemon lifecycle | | Readiness connector | withconnectorarc | Use a stable, unique connector ID and bounded readiness checks | | Native delegation router | withdelegationrouterarc | Return ownership only for the remote delegations the module can route | | State-complete HTTP router | withhttpmount | Declare every method and path, all under /connectors/ | An HTTP mount carries both an Axum router and explicit route claims. The daemon uses the claims for deterministic conflict checks. Keep the claims identical to the router's actual routes; the constructor does not infer them from Axum. Do not use a local HTTP mount to claim a core route such as /aip/v1/messages. Module-owned routes are restricted to /connectors/. 9. Review the trust boundary Before accepting a module, confirm all of the following: • maintainers review it as part of the daemon artifact; • descriptor is deterministic and never contacts a provider; • prepare is bounded, cancellation-safe, and contains no unbounded discovery; • every handler uses handlewithcontext for trusted runtime state; • a trust-dependent handler rejects the context-free handle path; • identity comes from context.actor, never from action input; • tenant, credential, approval, transaction, and redaction data come only from their matching context fields; • logs and errors omit action payloads, credentials, and provider responses; • manifest contracts describe only behavior the handler implements; • module and remote fleet implementations never claim the same capability ID; • the deployment accepts same-process memory, CPU, store, and crash exposure. ActionExecutionContext is deliberately non-serializable. It contains the transport-authenticated actor, optional verified tenant, opaque credential handle, deadline, cancellation token, idempotency reservation, and approval. It also carries transaction state, checkpoint publishers, a stream, trusted trace identifiers, and the redaction policy. Do not reconstruct it from an AIP payload. Resolve common failures | Symptom or code | Meaning | Bounded action | | aipd.module.invalidid | The module ID violates the static format | Fix the compile-time ID; do not normalize it silently | | aipd.module.invalidtimeout | The timeout is zero or exceeds five minutes | Choose a bounded value within the supported range | | aipd.module.duplicateid | Two factories own the same module ID | Remove one owner or assign a stable distinct ID | | aipd.module.startuptimeout | Preparation exceeded its descriptor timeout | Bound the preparation work; inspect whether the module must remain required | | aipd.module.descriptorchanged | prepare returned a different descriptor | Return the exact static descriptor from the prepared module | | aipd.module.manifestlimit | The serialized contribution exceeds 4 MiB | Move generated or tenant-specific catalog data out of the local manifest | | aipd.module.handlermanifestmismatch | Callable capabilities and handlers differ | Add the exact missing handler or remove the undeclared handler | | aipd.module.capabilityconflict | Core or another module owns the capability ID | Preserve one canonical owner and change the manifest before retrying startup | | aipd.module.connectorconflict | Two modules return the same readiness connector ID | Preserve one connector owner or assign a stable distinct ID | | aipd.module.reservedhttproute | A module route is outside /connectors/ | Move it under the module namespace; never shadow a core route | | aipd.module.httprouteconflict | Two modules claim the same method and path | Keep one owner and update both claims and router code together | | Daemon starts without an optional capability | Its prepare call failed or timed out | Inspect /ready, fix the bounded failure, and redeploy; do not invoke an undiscovered capability | Preserve the first startup error. Repeated restarts cannot repair a deterministic manifest or ownership conflict and can hide the original evidence. Roll back the compiled composition A local module cannot be unloaded or hot-swapped. To roll it back: 1. stop routing new work to the affected daemon artifact; 2. preserve the startup error and deployed source identity; 3. remove the matching .withmodulefactory(...) registration; 4. rebuild from the last approved dependency and configuration set; 5. deploy the replacement artifact through the normal rollout boundary; 6. verify that the capability is absent from the manifest; 7. verify that the old module ID is absent from /ready; 8. reconcile any actions accepted before the old process stopped. Removing the factory changes discovery after redeployment. It does not cancel, reverse, or settle work already persisted by the previous process. Related documentation • Use the Rust SDK (use-rust-sdk.md) to select and pin the required crate boundaries. • Read Capabilities and contracts (../concepts/capabilities.md) before defining the manifest and handler support matrix. • Read Actions and sessions (../concepts/actions-and-sessions.md) for durable lifecycle, idempotency, cancellation, and unknown outcomes. • Read Identity and trust (../concepts/identity-and-trust.md) before consuming authenticated actor, tenant, credential, or approval context. • Read Profiles, transports, and connectors (../concepts/profiles-and-connectors.md) before choosing local placement instead of the remote fleet.