Implementing an Attachment Service
SkillDocs & knowledgeLets your agent write file attachments into the Macro team workspace alongside email, chat, docs, and tasks.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Implementing an Attachment Service skill
About this capability
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
What this skill tells your AI
The instructions your AI receives, as published by macro-inc/macro in crates/attachment/.claude/skills/WRITE_ATTACHMENT_SERVICE/SKILL.md and read by ahel’s review.
You are adding an inbound/attachment module to a domain crate. This module implements the attachment::AttachmentService trait so the crate can resolve its entity IDs into AI-consumable attachment content.
Context files to read first
Read these files in order to understand the patterns:
attachment/src/lib.rs— theAttachmentServicetrait andAttachabletraitattachment/src/models.rs—AttachmentContent,AttachmentPart,Attachments,ResolutionError,AttachmentErrorattachment/src/fmt.rs—XmlTag,ClosedXmlTag,Indentformatting utilitiesattachment/src/attachable.rs—Attachableimpls that convert the attachment tree intoFormattedPartsdocuments/src/inbound/attachment/— reference implementation (module structure, service, markdown submodule)chat/src/inbound/attachment/service.rs— example usingfmt::XmlTagfor structured message formatting
All paths are relative to crates/.
What you're building
A struct that implements AttachmentService:
pub trait AttachmentService: Send + Sync + 'static {
fn resolve_attachments(
&self,
user_id: MacroUserIdStr<'_>,
ids: NonEmpty<&[&str]>,
) -> impl Future<Output = Attachments> + Send;
}
It takes entity IDs and returns Attachments — a non-empty vec of Result<AttachmentContent, ResolutionError>. Individual failures never fail the batch.
Key types
AttachmentReference— typed enum identifying the source entity (DssFile,SfsImage,EmailThread,Chat,Channel). Produces XML attributes viaas_attributes().AttachmentContent { reference: AttachmentReference, name: Option<String>, content: NonEmpty<Vec<AttachmentPart>> }— a resolved attachmentAttachmentPart::Content(String)— text contentAttachmentPart::Image(ImageData)— image data (fromai::types::ImageData)AttachmentPart::Child(Box<Result<AttachmentContent, ResolutionError>>)— nested sub-attachment with its ownAttachmentReferenceAttachmentPart::ChildReference(AttachmentReference)— unresolved reference to a child attachmentAttachmentPart::Metadata { key, value }— key-value metadata (formatted as<metadata key="..." value="..."/>)ResolutionError::new(id, AttachmentError)— a per-attachment failureAttachments::new(NonEmpty<Vec<Result<AttachmentContent, ResolutionError>>>)— the batch result
Formatting types (attachment::fmt)
XmlTag { name, attrs, body }— wraps body content in<name attrs>...</name>. Implements bothDisplay(for producingString) andAttachable(for producingFormattedPartswith images preserved).ClosedXmlTag { name, attrs }— self-closing<name attrs/>. Use for metadata and unresolved references.Indent(T)— indents all text lines of the wrapped value.
Steps
1. Add dependencies to the crate's Cargo.toml
Add an attachment feature that pulls in what you need:
[features]
attachment = [
"dep:attachment",
# add other deps your resolver needs (dep:ai, dep:reqwest, etc.)
"ports", # if the crate gates its domain traits behind a feature
]
[dependencies]
attachment = { path = "../attachment", optional = true }
non_empty = { path = "../non_empty" }
2. Wire up the inbound module
In src/inbound.rs (or equivalent), add:
#[cfg(feature = "attachment")]
pub mod attachment;
Make sure lib.rs compiles the inbound module when the attachment feature is active.
3. Create the module structure
src/inbound/attachment/
├── mod.rs — declares submodules, re-exports the service struct
└── service.rs — the AttachmentService implementation
Add more files if you need type-specific resolution logic (like markdown.rs in the documents reference impl).
4. Implement the service
Your service struct holds Arc references to whatever domain services and clients it needs. Follow this pattern:
pub struct FooAttachmentService<Svc> {
service: Arc<Svc>,
}
impl<Svc: FooService> AttachmentService for FooAttachmentService<Svc> {
async fn resolve_attachments(
&self,
user_id: MacroUserIdStr<'_>,
ids: NonEmpty<&[&str]>,
) -> Attachments {
let user_id = &user_id;
let results = join_all(ids.iter().map(|id| async move {
self.resolve_one(user_id, id)
.await
.map_err(|error| ResolutionError::new(id.to_string(), error))
}))
.await;
Attachments::new(NonEmpty::new(results).expect("ids was non-empty"))
}
}
Then implement resolve_one which returns Result<AttachmentContent, AttachmentError> for a single entity. This is where the domain-specific logic lives.
5. Add the AttachmentReference variant
If your entity type doesn't have a variant in AttachmentReference yet, add one in attachment/src/models.rs. Update id(), as_attributes(), and the AttachmentProvider router in provider.rs.
6. Register with the provider
Add your service as a type parameter to AttachmentProvider in attachment/src/provider.rs and wire up the dispatch.
Conventions
- Use
join_allto resolve IDs concurrently. - Map domain errors to
AttachmentError::Internal(e.into())unless a more specific variant fits (PermissionDenied,UnknownFileType,UnsupportedFileType,NoContent). - Access-check each entity via
EntityAccessService::generate_entity_access_receiptbefore reading content. - Use
tracing::instrumentonresolve_onewithskip(self)anderr. - Use
attachment::fmtutilities (XmlTag,ClosedXmlTag) for structured text formatting (e.g. wrapping messages in<message role="user">tags). UseXmlTag.to_string()when producing anAttachmentPart::Content(String). - For sub-attachments (e.g. inline images within a document), use
AttachmentPart::Childwith the appropriateAttachmentReferencevariant. This flows through theAttachableformatting system and produces properly tagged XML output.
NonEmpty rules
Never insert empty or placeholder strings into NonEmpty to satisfy the non-empty constraint. The NonEmpty<Vec<AttachmentPart>> in AttachmentContent.content means "this attachment has content." If resolution produces zero parts, return AttachmentError::NoContent instead.
// WRONG — defeats the purpose of NonEmpty
let content = NonEmpty::new(parts).unwrap_or_else(|_| {
NonEmpty::new(vec![AttachmentPart::Content(String::new())]).expect("single element")
});
// RIGHT — signal that this attachment has no content
let content = NonEmpty::new(parts).map_err(|_| AttachmentError::NoContent)?;
Signals
- GitHub stars
- 4k
- Forks
- 409
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
write-attachment-service- Source
- github.com/macro-inc/macro