Skip to main content

I Built the Capture Layer for My Portable Personal Context

· 8 min read

A developer writing the day's notes by lamplight, watercolor illustration

Every session now leaves behind a timestamped note I can review, audit, or delete. No cloud sync. No hidden database. Just files I own.

I built the missing memory half from my personal context blog post.

In Portable Personal Context Across AI Client Surfaces, I separated personal context from memory. Context is the small set of reviewed facts I want tools to trust. Memory is what happened while I worked.

The gap was capture. I needed a way for Copilot CLI sessions to leave behind structured observations I could review later. So I built copilot-cli-log-to-file: a Copilot CLI extension that writes each finished turn to a timestamped file I own.

The result: a reproducible, human-controlled pipeline from raw evidence to trusted rules—without accidental promotions.


Capture the memory feed first

The stronger angle is memory feed, not cross-computer sync.

Sync is useful, but it comes later. What I needed first was a raw feed: timestamped session evidence that could become an observation in the pipeline.

observation  →  candidate  →  [ratification gate]  →  context
(memory) (proposed) (a human decision) (canonical)

A capture file is not context. It can prove that I asked a question, that Copilot answered, and that a tool ran if I opted into tool capture. It should not silently become an instruction. I still want the human gate because one weird session should not rewrite my operating rules.

Extension output

copilot-cli-log-to-file runs after a Copilot CLI turn finishes. By default, it writes a complete YAML document with timestamp, sessionId, prompt, and response.

The default filename pattern is {timestamp}-{prompt30}.yaml, so a log folder looks like this:

copilot-response-log/
2026-07-13T11-21-45Z-list-all-my-files.yaml
2026-07-13T14-05-02Z-explain-this-function.yaml

That filename format mattered more than I expected. I can sort by time, skim the prompt slug, and delete one turn without touching the rest of the history.

I kept enriched capture off by default. If I want more detail, I opt in by category: attachments, reasoning, tool calls, tool results, usage, model changes, skills, subagents, permissions, errors, lifecycle events, turn boundaries, schedules, or notifications. There is also a COPILOT_LOG_CAPTURE_ALL=true override, but I treat that as a deliberate choice because these files can hold private data.

Here is a small capture with usage and tools enabled:

timestamp: "2026-07-14T18:21:45.000Z"
sessionId: "9f4c2a7b12345678"
prompt: |-
Summarize the current branch and suggest the next test to run.
tools:
- toolCallId: "call_abc123"
toolName: "git"
arguments: '{"command":"status --short"}'
success: true
result: "M website/blog/2026-07-17-capture-layer-for-portable-context.md"
usage:
- model: "gpt-5"
inputTokens: 1840
outputTokens: 420
cacheReadTokens: 600
cacheWriteTokens: 0
duration: 1310
finishReason: "stop"
response: |-
The branch has one modified blog post. The next targeted validation is to re-read
the file for frontmatter, link, and YAML-example accuracy before committing.

The toolCalls and toolResults toggles feed one merged tools: block. That was a small design choice, but it keeps the question I care about in one place: what tool ran, with which arguments, and what happened.

Two implementation details saved me from future debugging. Copilot CLI reserves stdout for JSON-RPC, so I use session.log(...) instead of console.log(...). The runtime also provides @github/copilot-sdk; I do not ship it as an npm runtime dependency. js-yaml stays dev-only for tests because the extension has a hand-rolled YAML emitter.

The capture layer transforms a CLI turn into structured data. A turn contains two key outputs: the assistant response (Copilot's answer) and events (the structured record of what happened—tool calls, model usage, and other metadata). Both flow into the timestamped YAML file.

Copilot CLI capture layer architecture

Promote observations only after review

The previous blog post's pipeline feels less abstract now that I have real files on disk.

copilot-response-log/*.yaml

observation

candidate

[ratification gate]

portable personal context repo

The YAML file is the observation. A candidate is the proposed durable fact I might extract from one or more observations. The context repo is where the approved rule lives.

For example, a few sessions might suggest that I prefer targeted validation before full-suite validation for docs-only work. That is still only evidence. I decide whether it belongs in process/quality-bar.md, decisions/_active.md, or nowhere.

Pipeline stageSourceStatusExample
ObservationYAML captureRaw evidence"This session used a targeted validation step."
CandidateExtracted proposalNot authoritative"Maybe targeted validation is preferred."
RatificationHuman reviewDecision point"Yes, make this a workflow rule."
ContextMarkdown repoCanonical"For docs-only changes, re-read the edited file first."

Boring is good here. The capture layer does not promote anything by itself. That keeps mistakes, one-off exceptions, and prompt-injection garbage out of my canonical context unless I approve them.

Observation to context ratification pipeline

Keep the feed in files I can inspect

I chose files because I wanted the memory feed to stay under my control—fully inspectable and modifiable.

A hidden store might be easier for a product team to manage. It is less helpful when I want to understand what happened. With YAML files, I can open a turn, grep the folder, parse a subset, redact a bad capture, or delete a whole day.

ChoiceWhy I used it
YAML documentsA parser can load each file directly. I can still read it.
One file per turnI can archive or delete one capture without editing a combined log.
Configurable folderI can keep logs local or point them at a private synced location.
Plain text optionI can choose readability when structured ingestion is not the goal.

I do not need a private service to prove the idea. I need a folder that my tools can read and that I can clean up when a log catches something I do not want to keep.

Treat logs as private-tier data

These logs hold my secrets if I let them.

A prompt can include file paths, pasted snippets, internal project names, tool arguments, tool results, permission prompts, errors, and final responses. Enriched capture defaults to off because the safe default is a small capture. When I turn on tools or usage, I am choosing to record more evidence.

So I treat copilot-response-log like the private tier from my personal context blog post:

  • I keep raw captures local unless I have a private sync policy.
  • I scrub before committing any log-derived material.
  • I do not point every AI surface at the raw folder.
  • I enforce trust tiers before retrieval.

That last point is the security boundary. If an untrusted surface can read my raw logs, filtering the final answer is too late.

Private-tier boundary for raw Copilot CLI logs

Sync across computers only after the capture layer exists

Once the feed is files, cross-computer sync becomes straightforward. I can point the extension at a folder backed by git, OneDrive, or another private file-sync tool.

That still does not make every surface smarter. Each surface needs wiring, and I do not want most surfaces reading raw logs anyway.

Computer A: Copilot CLI → copilot-response-log/*.yaml
Computer B: Copilot CLI → same synced folder
Review step: observations → candidates → approved context
Wired surfaces: read approved context, not raw logs by default

The central second brain is not the log folder. It is the loop: capture evidence, review candidates, then promote the facts I trust into portable context. The raw feed stops useful session evidence from vanishing. The curated repo stops tools from relearning the same facts twice.

Keep the next step boring

This is not automatic yet. Ratification is still human. Candidate extraction still needs review. Each AI surface still needs its own way to read approved context. There is no shared $AI_CONTEXT_PATH that every tool honors.

That is okay for this step. I built one small piece: a Copilot CLI extension that captures my missing memory feed as structured files. Now the architecture from my personal context blog post has something concrete to promote.

What's next: candidate extraction and ratification

The capture layer answers "how do I collect evidence?" The next piece is "how do I turn evidence into trusted rules?"

That means three things:

  1. Candidate extraction — Tooling that reads a week of captures and proposes facts worth considering. "You ran targeted validation before full-suite validation in 5 docs-only sessions. Is this a pattern?"
  2. Ratification interface — A simple review UI where I approve, reject, or refine each candidate before it becomes canonical. No auto-promotion. No surprises.
  3. Context repo publishing — Once approved, a candidate becomes a rule in the portable personal context repo. Then every surface that reads that repo knows about it.

Until then, the captures sit in their folder as raw evidence. They prove what happened. They don't decide anything. That's the safety boundary I chose to keep.

Next session, I'll build the ratification loop.

Portable Personal Context Across AI Client Surfaces

· 16 min read

Many developers use multiple AI surfaces daily: GitHub Copilot in VS Code, Copilot CLI, Microsoft 365 Copilot, Microsoft Scout, Claude, ChatGPT, or Cursor.

The problem: each surface starts without the context you gave the last one. Preferences, current work, boundaries, and decisions stay trapped in whichever tool you told.

This post proposes a portable personal context source: structured markdown files in a GitHub repo that AI tools can read. No vendor supports this end-to-end today. The storage can be portable; the behavior is not automatic. Each surface still needs its own wiring.


The Problem: Context Islands

Context islands diagram showing isolated AI surfaces

SurfaceWhat it knows about youWhere that knowledge lives
Copilot in VS Code.github/copilot-instructions.md in current repoPer-repo, per-machine
Copilot CLILocal instructions, skills, plugins, MCP servers, and session dataCLI-specific local state
Microsoft 365 CopilotYour M365 Graph data (emails, calendar)Cloud, not exportable
Microsoft ScoutMemories, preferences, profileLocal app state
ClaudeCLAUDE.md per project, memoryPer-project file + cloud memory
CursorProject Rules in .cursor/rules/*.mdc, plus AGENTS.md supportPer-project rules

The pattern: each tool has its own user-context format. The result:

  • Repeated preferences in every tool ("I prefer concise output," "use TypeScript," "don't auto-push to main")
  • Decisions invisible outside the surface where they happened
  • Expertise and boundaries known only where you stated them

You Already Know What the Solution Feels Like

Personalization features exist in every tool but are locked to that tool

Built-in FeatureWhat it doesThe problem
M365 Copilot: Custom instructions"Be concise, use tables"Doesn't reach VS Code or CLI
M365 Copilot: Work profileYour role, org, skillsLocked in Microsoft Graph
M365 Copilot: Saved memoriesFacts remembered between sessionsOnly M365 Copilot sees them
ChatGPT: MemoryAuto-extracted facts about youOnly ChatGPT sees them
Claude: CLAUDE.mdPer-project instructionsOnly Claude Code sees them
Cursor: RulesCoding preferencesOnly Cursor sees them

Each tool stores personalization separately. Portable context makes the source shared:

Today:
M365 Copilot → knows you like concise output
VS Code Copilot → doesn't know (asks again)
Copilot CLI → doesn't know (asks again)
Scout → has its own separate copy
Claude → has its own separate copy

With portable personal context:
Wired surfaces → read from the same source → load your preferences

Why Not Just Use Those Existing Features?

Built-in PersonalizationPortable Context
PortabilityOne surface onlyShared source; manual wiring per surface
TransparencyOpaque ("View work data")Human-readable markdown
ExportabilityCan't exportgit clone anywhere
VersioningNo historyFull git history
ControlPlatform decides formatYou decide format
DecisionsNo structured logAppend-only ledger
Auto-extractionYes (convenient)Manual (precise)

Use built-in personalization where it exists; keep portable context as the canonical source you can inspect and version.


Why Not CLAUDE.md, copilot-instructions, or Cursor Rules?

Those files are instructions TO the AI for one project. Personal context is information ABOUT you across tools. Cursor's current model is Project Rules in .cursor/rules/*.mdc; .cursorrules is legacy.

Scope comparison showing per-tool files as narrow vs personal context as universal

.github/copilot-instructions.md  → "In this repo, use ESM imports"
personal-context/process/... → "I always prefer ESM over CommonJS"

Repo-level files govern a codebase. Personal context governs how to work with you.


Why Not Just an Agent or Skill?

Agents and skills are task-scoped. Personal context is user-scoped.

Persona hierarchy showing person above process above skills above agents

Personal ContextAgent (agent.md)Skill (SKILL.md)
Answers"Who is this person?""How should I behave?""How do I do this task?"
ScopeEverything you doOne role or surfaceOne repeatable procedure
LifespanYears (grows with you)Months (evolves with tooling)Weeks (refined per use)
PortabilityShared source across wired surfacesOne surfaceSome surfaces

Personal context should feed agents and skills, not duplicate them. Without it, agents start without your quality bar, boundaries, or past decisions.


Why Not Mem0 or a Cloud Memory Service?

Mem0 is a cloud API for persistent AI memory. It makes different tradeoffs:

Mem0 (Cloud)Personal Context Repo
ArchitectureHosted API serviceLocal-first (files in git)
Data ownershipThird-party hostedYou own it (your GitHub)
Works offlineNoYes
Vendor dependencyYes (Mem0 API key)No (just git)
Human-readableNo (vector store)Yes (markdown you can edit)
VersionedNo (mutable state)Yes (git history + blame)
Semantic searchYes (their strength)No (not needed at personal scale)
Best forApp builders serving many usersIndividual developers across their own tools

For one developer, dozens of curated facts, decisions, and preferences may not need a hosted dependency.


Personal Context Is Not Memory

Context vs Memory promotion pipeline

Key distinction: personal context is not memory. They overlap, but they need different storage, governance, and precedence rules.

Context is declared, curated, and authoritative. Memory is accumulated from use.

Personal ContextMemory
OriginAuthored intentionallyAccumulated automatically
NatureCurated / declaredAccreted / observed
AuthorityAuthoritative ("this is the rule")Evidentiary ("this is what happened")
Example"My branch naming convention is {type}/{id}-{slug}""Last Tuesday you renamed a branch to wip-2"
VolumeSmall, deliberate (~50-150 facts)High-volume, ever-growing
GovernanceHuman-reviewedAuto-captured

They are two ends of a promotion pipeline:

observation  →  candidate  →  [ratification gate]  →  context
(memory) (proposed) (a human decision) (canonical)

Memory is the raw feed. Context is the reviewed output. The ratification gate is the control point. The architecture changes:

  • Stores. Memory wants a high-volume append log. Context wants a small, curated, versioned set.
  • Precedence. Context outranks memory. A remembered exception does not override a stated boundary.
  • Retrieval and governance. Context is load-always instruction; memory is search-when-relevant evidence.

This post is about context, not memory: authored, reviewed, and portable.


The Insight: LLMs Already Speak Markdown

Markdown plus GitHub enables cross-tool portability

AI tools read files. LLMs understand markdown. Developer tools commonly authenticate with GitHub.

A private GitHub repo with structured markdown files can be the shared context source.

Any surface that can read the repo can load the same context.


The Architecture: Personal Context as a Repo

Architecture diagram showing canonical repo feeding multiple surfaces

github.com/<yourname>/personal-context  (placeholder private repo)

├── context.json ← Manifest: what's here + retrieval rules

├── core/ ← RARELY CHANGES (your "constitution")
│ ├── expertise.md # What you know, your domain authority
│ ├── boundaries.md # What stays human, what AI never does alone
│ ├── role.md # Job, scope, organization
│ └── communication.md # How you prefer to interact

├── decisions/ ← APPEND-ONLY (your "ledger")
│ ├── _active.md # Decisions still governing current work
│ ├── 2026-07.md # This month's new decisions
│ └── ...

├── process/ ← STABLE (your "playbook")
│ ├── content-workflow.md # How you create content
│ ├── code-workflow.md # How you write and ship code
│ ├── quality-bar.md # Definition of done per work type
│ └── tool-preferences.md # Preferred tools and patterns

├── active/ ← CHANGES OFTEN (your "whiteboard")
│ ├── projects.md # Current active projects
│ ├── sprint-focus.md # This sprint's commitments
│ └── parking-lot.md # Deferred items

└── .github/
└── copilot-instructions.md # Tells Copilot how to USE this repo

Why Four Layers?

Separate by durability: how often it changes and who can change it.

Four layers diagram showing durability spectrum

LayerHalf-lifeMutabilityExample
CoreMonths/yearsHuman-only"I'm a senior developer on the Azure SDK docs team"
DecisionsPermanent (append-only)Any surface proposes; append after human confirmation"Use generation pipeline for MCP namespace files"
ProcessWeeks/monthsPropose via PR"Branch naming: {type}/{id}-{slug}"
ActiveDays/weeksAny surface updates after pull-before-push"Sprint focus: Ship auth-flow feature"

What Goes in Each Layer

Core: Your Constitution

Rarely changing context: expertise, boundaries, communication preferences.

core/expertise.md — What you know:

## Domain Expertise
- Azure SDK documentation across JavaScript, Python, .NET, Java, Go, Rust
- AI developer tools (MCP servers, AI Toolkit, Copilot extensions)
- Content workflow automation and multi-agent orchestration
- Technical writing for developer audiences

## Not My Expertise (don't assume I know)
- Kubernetes operations / cluster management
- Frontend framework internals (React, Vue, etc.)
- ML model training / fine-tuning

core/boundaries.md — What stays human:

## What AI Should Never Do Autonomously
- Push code to upstream repositories (only to forks)
- Send emails, Teams messages, or any outbound communication
- Close or resolve work items without my confirmation
- Delete files, branches, or repos
- Make irreversible changes without showing me the plan first

## What AI Can Do Without Asking
- Read files, search code, explore repos
- Draft content for my review
- Run tests, linting, builds
- Create branches on my fork
- Propose edits (but not commit without confirmation)

Decisions: Your Ledger

Decisions can be proposed from any surface so settled questions stay settled after review.

decisions/_active.md — Still-relevant decisions:

### [2026-07-06] Branch naming convention
- **Context:** Inconsistent branch names across repos
- **Decision:** Always use `{type}/{work-item-id}-{brief-slug}`
- **Types:** feat, fix, docs, refactor, test

### [2026-06-15] Prefer tables over prose for comparisons
- **Context:** AI kept writing long paragraphs comparing options
- **Decision:** When comparing 3+ options, always use a table
- **Supersedes:** Nothing (new preference)

### [2026-05-28] No hand-written namespace files
- **Context:** Generated files were higher quality than hand-written
- **Decision:** All namespace articles must come from the generation pipeline
- **Implications:** Slower to ship, but deterministically correct

Process: Your Playbook

Work preferences. Update as workflow changes.

process/quality-bar.md:

## When Is a Pull Request Done?
- [ ] Work item linked with "Fixes AB#{id}"
- [ ] Meaningful title and description (not just commit messages)
- [ ] No unrelated changes (surgical edits only)
- [ ] CI passes
- [ ] Review comments addressed, not dismissed
- [ ] Staged preview links included for doc changes

## When Is an Article Done?
- [ ] Technically accurate (verified against product behavior)
- [ ] Code samples run without modification
- [ ] All links resolve (no 404s)
- [ ] Metadata correct (ms.topic, ms.date, ms.service)
- [ ] Reviewed by at least 1 peer

Active: Your Whiteboard

Current work state, writable by any surface.

active/sprint-focus.md:

## Sprint 14 (2026-07-01 → 2026-07-12)

### Committed
1. Ship MCP auth namespace docs (AB#4521)
2. Review 3 community PRs on azure-dev-docs
3. Update AI Toolkit quickstart for v0.9

### Stretch
- Prototype portable context layer (this project!)

How Surfaces Consume It

Selective Retrieval: Only Load What's Relevant

Do not load the whole repo. Use context.json to choose task-relevant files:

1. Read context.json (< 1KB, always cached)
2. ALWAYS load: core/boundaries.md + core/communication.md (~400 words)
3. Classify the current task → match to load_by_task
4. Load those 2-3 files (~500 words)
5. Load decisions/_active.md (~300 words)

Total: ~1,200 words ≈ 1,600 tokens

Retrieval flow diagram

The Priority Stack

Resolve contradictions deterministically:

core/boundaries.md          ← ALWAYS wins. Non-negotiable.
decisions/_active.md ← Settled questions. Don't re-ask.
process/*.md ← How to do things. Follow unless overridden in-session.
active/*.md ← Informational state. Not authoritative.

Priority stack diagram

Threat Model: Retrieval Is the Boundary

The primary risk is prompt injection causing a surface to retrieve or reveal context it should not have. Trust tiers must be enforced before retrieval, by deciding what files or slices enter the prompt. Output scanning is not a security boundary; use it only as defense in depth. Keep two boundary files if needed: shareable operating rules that most tools can load, and private sensitive constraints that only trusted surfaces can retrieve.

Writing Back: Closing the Loop

After human confirmation, any surface can write decisions back:

# After making a decision in any surface:
cd ~/personal-context
echo "
### [$(date +%Y-%m-%d)] {decision title}
- **Context:** {why this came up}
- **Decision:** {what was decided}
- **Implications:** {what this means going forward}
" >> decisions/_active.md

git add decisions/_active.md
git commit -m "decision: {brief title}"
git push

That simple append is safe only for a single writer with a fresh clone. Multi-surface writes need write intents with IDs and timestamps, pull-before-push, and PR-based reconciliation for stale updates or conflicts. The core layer stays human-only; non-active layers should go through review instead of direct overwrite.


Connecting Each Surface (Proposed Integrations)

Examples only. Some work manually; others need vendor support. The mechanism: read files and inject context.

GitHub Copilot in VS Code

In your user-level settings.json:

{
"github.copilot.chat.codeGeneration.instructions": [
{ "file": "~/personal-context/core/boundaries.md" },
{ "file": "~/personal-context/core/communication.md" },
{ "file": "~/personal-context/decisions/_active.md" }
]
}

Or reference the repo in any project's custom instructions:

<!-- .github/copilot-instructions.md in any repo -->
For my personal preferences and decisions, reference:
https://github.com/<yourname>/personal-context

Copilot CLI

Use the current standalone copilot CLI. Put durable CLI instructions in $HOME/.copilot/copilot-instructions.md, then point those instructions at the cloned context repo:

mkdir -p ~/.copilot
cat > ~/.copilot/copilot-instructions.md <<'EOF'
For personal preferences and decisions, read:
- ~/personal-context/core/communication.md
- ~/personal-context/core/boundaries.md
- ~/personal-context/decisions/_active.md

Treat the repo as reference context. Do not rewrite core files without human approval.
EOF

For richer integration, expose the same repo through an MCP server and register it with copilot mcp.

Microsoft Scout

Scout exposes settings for memory, personality presets, workspace, and permissions. Use those surfaces to mirror the same repo-backed preferences manually or through a sync process. A generated profile file can work as an implementation pattern, but the path below is illustrative, not a documented Scout contract:

# Sync script: pull personal-context → render an illustrative Scout profile
$role = Get-Content ~/personal-context/core/role.md -Raw
$comms = Get-Content ~/personal-context/core/communication.md -Raw
$boundaries = Get-Content ~/personal-context/core/boundaries.md -Raw

@"
# Personal Profile
$role

## Communication
$comms

## Boundaries
$boundaries
"@ | Set-Content ./scout-profile-example.md

Microsoft 365 Copilot

Sync the repo to a OneDrive folder:

OneDrive/personal-context/ → synced from GitHub repo

Any MCP-Enabled Tool (Claude, Cursor, ChatGPT)

Expose the repo as an MCP resource server, or clone it locally and point the tool config to the files. MCP is the integration protocol for tools, resources, and prompts; use it to expose context as resources or tools where supported.


Getting Started: Example 30-Minute Setup

Timeline showing 30-minute setup in 4 steps

This is a rough first-pass estimate, not a guarantee. The ongoing cost is maintenance: review proposed changes, resolve conflicts, and prune stale active context.

1. Create the repo (5 minutes)

gh repo create personal-context --private
cd personal-context
mkdir -p core decisions process active .github

2. Write your identity (10 minutes)

Write what you would tell a new team member on day one.

3. Capture your first decisions (10 minutes)

Write five repeated preferences or decisions in decisions/_active.md.

4. Connect one surface (5 minutes)

Wire up VS Code settings, Scout profile, or a CLI alias. Verify it loads context.

5. Evolve naturally

When an AI asks something it should know, write it down, commit, and push.


The Payoff

Before and after comparison showing repetition eliminated

BeforeAfter
"I prefer concise output" (every session)A wired surface can load it from core/communication.md
"Use fork-first workflow" (every PR)A wired surface can load it from process/code-workflow.md
"We decided to use the pipeline" (re-explained monthly)A wired surface can load it from decisions/_active.md
"My sprint focus is X" (repeated across tools)A wired surface can read active/sprint-focus.md
Start over in each new toolStart from the same source after each tool is wired

Likely payoff: fewer repeated preferences, fewer re-decisions, and faster starts. Keep time-saved claims only if measured.


Beyond the Repo: When a Service Makes Sense

Context broker architecture with MCP facade and trust tiers

The repo is the floor: markdown, git, no server, no vendor, no API key. Use a service only when a flat repo cannot enforce access. Git gives readers the whole file; a hosted service can return only authorized slices.

What a hosted version would buy you

  • Server-side redaction by trust tier. Enforce public / work / private tiers at the server. A flat repo cannot do that; clone access gets everything.
  • Identity-based audit and access control. Log who read context, when, and from which surface.
  • Central precedence. Resolve boundaries, decisions, process, and active state once instead of per surface.

The key design call: contract vs. transport

Do not make MCP the canonical contract. MCP is the integration protocol, not the canonical data model. Keep Git as the source of truth. If you build a service, make it a derived read facade over the repo, with a REST/OpenAPI contract and MCP exposed as a thin facade where clients support it.

Keep the contract you can't afford to rewrite in Git and REST; expose MCP as a facade you can afford to replace.

Version the REST facade carefully. Treat MCP adapters as replaceable.

What the service actually is: a context broker

The service has four jobs:

  1. Merge — combine the layers (core, decisions, process, active) into one view.
  2. Priority — apply the precedence stack so conflicts resolve deterministically.
  3. Redaction — return only the caller's trust tier.
  4. Defense-in-depth scanning — flag output that appears to reveal a tier the caller should not see.

The third job is the security boundary for prompt-injection exfiltration. With server-side redaction before retrieval, private context never enters the prompt for an untrusted caller. The fourth job can catch mistakes, but it cannot make unsafe retrieval safe.

Even a service still hits the standards wall

The limitation: even with a hosted service, consumption stays uneven:

SurfaceTalks to a remote MCP server?
VS Code / Copilot CLI / FoundryYes — directly
Claude / ChatGPTYes — directly where the surface, plan, and auth model allow remote MCP
Microsoft 365 CopilotNo — it wants Graph connectors / declarative agents

The hosted version still needs a shared standard. For one developer, the repo is usually enough. A service earns its complexity only with multiple trust tiers, multiple consumers, or a real injection threat model.


What's Next: The Standard That Doesn't Exist Yet

Convergence diagram showing vendors approaching a missing standard

This post is a proposal, not a product announcement. Today, none of this works automatically. Each tool reads its own context files, in its own format, from its own location. Vendors are adding memory, custom instructions, project files, and agent profiles, but not a shared context standard.

What's missing is a shared standard for where personal context lives and how to read/write it. The Model Context Protocol standardized tool integration; user identity needs the same kind of agreement. No shared standard exists today.

The GitHub repo approach is a bet: structured markdown plus a retrieval manifest could work if tool builders agreed to read it.

The ask to tool builders: Add an $AI_CONTEXT_PATH or equivalent. Let users point to markdown context. Portable context works when surfaces agree to read the repo.

Self-Hosting SDXL on Azure Container Apps: What the Vendor API Was Hiding

· 18 min read

This project started as hands-on image generation, not an abstract model experiment. I was working with SDXL (Stable Diffusion XL), an open text-to-image generation model, and the path felt natural: first run it locally on my own machine, then package the same work in a local container with Docker, then deploy that container to Azure Container Apps.

The local and local container stages were mostly smooth. They gave me enough confidence that moving from laptop to container to cloud would be more plumbing than discovery. The surprises showed up when self-hosting moved to Azure, where model files, process readiness, storage, and deployment behavior all became part of the system.

What the Model Produces

Before the surprises, here is what the self-hosted SDXL pipeline actually generates once it is loaded and running. These is direct output from the same code, unretouched.

Self-hosted SDXL output: a warm, sunlit coffee shop interior with bookshelves, wooden tables, and afternoon light through tall windows

This is the payoff I was working toward. The rest of the post is about everything that stood between the container starting and this image coming out.

The False Assumption

Calling a vendor API makes image generation look like one operation: send a request, get an image back. Self-hosting a generative model turns that single operation into a system I have to own.

I wanted control over inference settings, no lock-in to a hosted image API, and a cost model I could reason about. At the top level, the choice sounded simple: stop calling the vendor service and run the model myself.

That choice moved hidden responsibilities into my application boundary. I inherited the model runtime, storage lifecycle, readiness state, and deployment behavior. I also inherited the difference between persisted model assets and a process that has loaded the model and can generate an image.

The container was only the packaging format. Self-hosting meant owning everything the model needed after the container started.

The Architecture I Expected

The system shape looked straightforward before the edge cases showed up. Local code would become a web API, the web API would run in a container, and the deployed container would use external storage for the model cache.

The first version looked like this:

Expected architecture: local SDXL code wrapped in Flask, containerized, deployed to Azure Container Apps with cached model weights on an Azure Files share

The concrete version ran my Flask-wrapped SDXL (Stable Diffusion XL, an open image-generation model) code in Docker on Azure Container Apps (ACA, the managed container hosting service used here), with an Azure Files share (a network-attached mount) for cached model weights. An azd (Azure Developer CLI) postdeploy hook—an automation step after deploy—pulled the model.

The real deployment runs on CPU in Azure Container Apps: 4 vCPU, 16Gi memory, device=cpu, and 136Gi ephemeral storage, the container's local scratch disk, wiped on restart. That 4 vCPU / 16Gi shape requires an Azure Container Apps Dedicated (D4) workload profile; the Consumption plan caps at 4 vCPU / 8Gi. The mounted Azure Files share holds the model cache, because the SDXL assets are too large to treat as incidental container filesystem state.

That architecture was directionally right, but it left out most of the operational work.

What Was Actually Happening

The first clean picture hid two separate truths: the container could be up without running Flask, and model files could exist without the process being ready.

What actually happened: after deploy the placeholder static server command was preserved, so GET / returned 200 but /health and model routes returned 404, and /model/status reported not_started

Surprise #1: Persistent Storage Does Not Mean Warm Application State

The first lifecycle mistake was treating stored model assets as if they were the same thing as a ready application. Persistent storage can keep files between revisions, ACA's immutable deployment versions. It cannot keep a new container process warm.

Surprise 1: model files persist on the Azure Files share, but a new revision starts a cold process so /model/status reports not_started

After the first cold download succeeded, I expected the next revision to be warm. The model files were on the Azure Files share, the share was mounted, and the path existed.

Then the app reported readiness state held in the process's memory, through the /model/status state field:

{
"state": "not_started"
}

That looked wrong until I separated two states I had been mentally combining:

  • model assets persisted on disk
  • model loaded and ready in this process

Those are not the same lifecycle. The Azure Files share can be warm while the container process is cold. A new revision starts a new process. That process can see the cached files, but it still has to initialize the SDXL pipeline in memory.

The specific not_started state did not mean "the share is empty." It meant "this process has not begun loading the model." The useful ready state was ready, also reported by /model/status.

That distinction changed how I read status. A cold path downloads model assets and then loads the model. A warm path skips the download but still loads the model from the mounted cache. In my deployment, warm load from the cached share was about 48 seconds. Cold download took minutes.

Those are different costs, and they happen for different reasons. The deployment gate needed to care about readiness, not just file presence. Checking whether a directory exists is not enough. Checking whether the model cache is populated is not enough. The application has to report that this process is ready to serve generation requests.

Persistent storage keeps assets. It does not keep application memory warm. For model-serving systems, readiness is process state.

I built a small web console over these endpoints so I could see that distinction directly. Each endpoint the deployment depends on has its own section, and /model/status reports the process state in plain language: here it shows READY - Model weights cached on disk. /generate will load from cache, which is exactly the difference between "the files are on the share" and "this process can answer a request."

SDXL API Console web UI with one section per endpoint: GET /api, GET /health showing HEALTHY on device cpu, GET /model/status showing READY with model weights cached on disk, POST /model/pull, and a POST /generate form with prompt, steps, guidance, size, and Force CPU options

Surprise 1 fix: a deployment gate checks readiness while the cached share warm-loads the model in about 48 seconds before serving

Surprise #2: Model Files Are Not Just Files

The next storage mistake was treating model acquisition as a small deployment detail. For a generative model, the weights are a deployable asset with their own lifecycle.

A model that is about 7GB in FP16, or roughly 14GB loaded as FP32 on CPU the way this deployment runs it, is not a small deployment detail. It is its own deployment phase.

Surprise 2: the empty model share plus a download that assumed POSIX flock broke on Azure Files SMB, so weights never appeared

The model weights do not appear during azd up. Infrastructure provisioning creates the place where the model can live, but it does not populate that place with model assets.

I made model acquisition part of postdeploy. The deployment automation step that runs after deploy, the azd postdeploy hook, calls the app's model-download endpoint, POST /model/pull:

curl --fail -X POST "$APP_URL/model/pull"

Then it blocks until the app reports through the model-status endpoint, /model/status, that the model is ready:

curl --fail "$APP_URL/model/status"

A useful response includes the process readiness state, target device, and model path:

{
"state": "ready",
"device": "cpu",
"model_path": "/models/stable-diffusion-xl-base-1.0"
}

That was the right shape, but the storage layer had its own constraints. The mounted network-attached file storage is an Azure Files share, which uses Server Message Block (SMB, the network file-sharing protocol Azure Files uses). SMB does not support POSIX flock, the file-locking call a local Linux filesystem supports. The first version of the download logic assumed file locking would behave like local disk, and that assumption broke on the mounted share.

That kind of bug feels like an operating system problem until you remember that self-hosting makes the filesystem part of the application architecture. I had to rework the download logic so the app did not depend on unsupported locking behavior on the mounted share.

Once that was fixed, the cold download was faster than I expected: about 2 minutes 23 seconds over the Azure backbone. The newer Hugging Face transfer path helped here; hf_xet, Hugging Face's newer fast download transport, replaced the deprecated hf_transfer, and the transfer itself was not the bottleneck I feared.

Surprise 2 fix: a postdeploy hook posts to /model/pull, downloads without flock over hf_xet, and polls /model/status until ready

The useful takeaway was not simply that downloads can be fast. The model is a deployable asset with its own lifecycle. With a vendor API, the weights are someone else's problem. With self-hosting, model acquisition needs ordering, retries, logs, status, and a failure mode that stops the release instead of hiding the problem until the first image request.

Surprise #3: "CPU Offload" Doesn't Work on a CPU

The runtime mistake was trusting a helper name before checking the hardware contract behind it. I expected memory to be an issue, and it was. The memory-saving helper I reached for had a name that sounded perfect for CPU hosting but failed because the container was actually running on CPU.

Surprise 3: on a pure-CPU container, enable_model_cpu_offload expects an accelerator and errors with requires accelerator but not found

In diffusers, Hugging Face's Python library for running diffusion image models, the helper is enable_model_cpu_offload():

pipe.enable_model_cpu_offload()

The name sounds like exactly what a CPU deployment wants. I read it as: use CPU memory carefully, offload model pieces as needed, survive inside the container limits.

That is not what it means. On a pure-CPU container, it raises the kind of error that makes the naming clear:

requires accelerator, but not found

enable_model_cpu_offload() means "offload to CPU from an accelerator." It is for a system that has an accelerator and wants to move parts of the model back to CPU memory. It is not a CPU execution mode.

The fix was explicit CPU-safe initialization:

pipe = StableDiffusionXLPipeline.from_pretrained(
model_path,
torch_dtype=torch.float32,
use_safetensors=True,
)

pipe.to("cpu")
pipe.enable_attention_slicing()
pipe.vae.enable_slicing()
pipe.vae.enable_tiling()

The idiomatic diffusers pipeline-level equivalents are pipe.enable_vae_slicing() and pipe.enable_vae_tiling(); both forms are equivalent.

The memory-saving calls here are literal: attention slicing computes attention in smaller chunks, and VAE (the variational autoencoder stage that decodes latents into the final image) slicing and tiling decode the image in pieces.

Model libraries encode hardware assumptions. Sometimes those assumptions are obvious. Sometimes they are hidden inside method names that sound like they were written for your exact scenario.

Surprise 3 fix: move the pipeline to CPU with pipe.to(cpu) plus attention slicing and VAE slicing and tiling to fit memory

For this deployment, "CPU offload" means "offload to CPU from somewhere else." It does not mean "run on CPU." The app is not just my Flask routes; it is also the model runtime, tensor dtype, memory behavior, and hardware profile lining up correctly.

Surprise #4: The Container Was Running, But Not My App

The startup mistake was using a running container as proof that my application was running. A container can be healthy enough to accept traffic while the wrong process is listening.

Surprise 4: azd deploy preserves the placeholder command, leaving the static server running so GET / returns 200 and app routes return 404

The container app was up, the revision existed, the endpoint responded, and GET / returned 200. Every real route still returned a 404 Not Found from Python's static file server, with this line in the HTML body:

Message: File not found.

At first, that looked like my Flask routing was broken. Maybe the app was not binding correctly. Maybe the container port was wrong. Maybe the health route was missing. Maybe the image was stale.

The error page pointed to the real problem. Message: File not found. is not Flask's default response; it is Python's SimpleHTTPRequestHandler, the built-in static file server returning its HTML error page. My container was running, but my Flask app was not.

On a fresh environment, azd provisions the Azure Container App before the real application image exists. To make the infrastructure deployment succeed, it uses a temporary placeholder web server, python3 -m http.server 8000:

python3 -m http.server 8000

That is reasonable during provisioning. The surprise came later, when azd deploy swapped in my real Flask image and preserved the placeholder command. The image changed, but the runtime command did not.

So my real container image started successfully and then ran Python's static file server instead of my Flask app. That is why / returned 200, and why /health, /model/status, and /model/pull returned 404 Not Found responses whose HTML body said Message: File not found. Those routes only exist in Flask, and Flask was never running.

I stopped treating "container is up" as proof that the application is running. I added a self-heal step in the postdeploy hook that resets the command explicitly:

az containerapp update \
--name "$CONTAINER_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--command "python3" "app.py"

Then the hook waits for the actual application route before it does any model work:

curl --fail "$APP_URL/health"

Surprise 4 fix: reset the container command to python3 app.py so Flask starts, /health responds, and model work continues

Only after /health responds from Flask does the deployment continue. Calling /model/pull before proving Flask is running is just sending a request to whatever process happens to be listening.

I now treat the command, the image, and the health endpoint as three separate facts. The deployment is not ready until all three are true.

The self-heal works, but it treats a symptom. The root cause is that the container command was set in the Bicep template, and azd deploy swaps only containers[0].image, so the placeholder command survives and overrides the image's own start command. The cleaner pattern is to put CMD ["python3", "app.py"] in the Dockerfile, remove command and args from the Bicep entirely so the image command is used, and gate readiness with an ACA startup probe on /health instead of a manual wait loop. I kept the self-heal hook because it is what is working in this deployment, but if I were starting clean I would remove the Bicep command override and let the image plus a startup probe do this job.

Surprise #5: Tooling Silence Is Also a Failure Mode

The verification mistake was trusting quiet tooling. Some failures throw obvious errors. Others look like nothing happened.

Surprise 5: silent tooling failures - invisible hook stdout, invalid azure.yaml keys defaulting the image, and a circular Bicep dependency

One problem was visibility. The azd postdeploy hook was running, but when its output was piped or non-interactive, stdout was invisible. Nothing in the terminal made it obvious what the hook was doing, so I verified through the platform logs:

az containerapp logs show \
--name "$CONTAINER_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--follow

Those logs became the source of truth.

Another problem was configuration shape. I had invalid keys in azure.yaml during one iteration. A top-level dockerfile: or port: looks plausible if you are moving fast, but azd did not fail the way I wanted. It ignored the invalid shape and fell back to default behavior.

The Dockerfile must be configured through the supported docker: block in azure.yaml:

services:
image-generation:
project: .
language: docker
host: containerapp
docker:
path: ./Dockerfile.cpu
context: .

That small indentation decision changed what image was built.

The Dockerfile also had to default to the Flask server as its entrypoint. If the platform command was absent or wrong, the image still needed to know how to run the app:

CMD ["python3", "app.py"]

Without that default, ACA could end up in ContainerBackOff or ActivationFailed, ACA states for a container that cannot start or stay up, depending on which part of startup failed.

There was also an infrastructure bug: a circular dependency in the Bicep, Azure's infrastructure-as-code language, for the container app failed template validation until I broke the cycle. That was not an SDXL issue. The model deployment made the infrastructure graph more complicated, and the graph had to be correct before the app could even try to start.

Final architecture: verify logs as source of truth, use a supported docker block, rely on the Dockerfile CMD default, and break the Bicep cycle for an observable deploy

Automation needs observable verification. In this setup, a successful command did not prove the deployment was correct, a running container did not prove Flask was running, a mounted share did not prove the model was ready, and a quiet hook did not prove the hook was idle.

What the Final Architecture Became

The final shape is the concrete version of the earlier diagram. Each piece now has an explicit responsibility, and deployment gates on the application being ready, not just the infrastructure existing.

The container image defaults to Flask:

CMD ["python3", "app.py"]

The runtime behavior is explicitly CPU:

device=cpu
vCPU=4
memory=16Gi
ephemeral storage=136Gi

The model pipeline uses CPU-safe initialization:

pipe.to("cpu")
pipe.enable_attention_slicing()
pipe.vae.enable_slicing()
pipe.vae.enable_tiling()

Here too, the pipeline-level pipe.enable_vae_slicing() and pipe.enable_vae_tiling() calls are the idiomatic diffusers form.

The Azure Developer CLI configuration points at the CPU Dockerfile through the supported shape:

services:
image-generation:
language: docker
host: containerapp
docker:
path: ./Dockerfile.cpu
context: .

The postdeploy hook does four jobs, in order:

  1. Reset the container command to the Flask app.
  2. Wait for /health so I know Flask is actually running.
  3. POST /model/pull so model acquisition is part of deployment.
  4. Poll /model/status until state is ready, with a configurable timeout and fail-fast behavior.

In shell form, the core idea is simple:

az containerapp update \
--name "$CONTAINER_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--command "python3" "app.py"

curl --fail "$APP_URL/health"
curl --fail -X POST "$APP_URL/model/pull"

until curl --fail "$APP_URL/model/status" | grep '"state":"ready"'; do
sleep 10
done

The real script has more defensive handling, because production scripts should fail clearly. But that is the architecture.

The app owns readiness. The hook gates deployment on readiness. Logs validate reality. I did not end up with just a container that runs SDXL; I ended up with a deployment lifecycle for a self-hosted generative model.

The Decision Framework I Actually Trust Now

I still like the decision to self-host for this project. The tradeoff is just clearer now.

Self-hosting buys control over inference settings, portability, model loading strategy, and deployment lifecycle. It also moves hidden responsibilities into your application boundary: runtime assumptions, model storage, download orchestration, readiness, deployment verification, logs, sizing, and the difference between "files exist" and "the model can answer this request."

A vendor API charges for convenience, but the convenience is real. It is not just inference. It is the operational surface area you do not have to build.

For a prototype, that surface area may not be worth it. For a workflow where settings, portability, and control matter, it can be.

The question I trust now is simpler: do I want to own everything this model needs to be reliable?

Closing

Self-hosting SDXL showed me how much the vendor API had been handling. Once I owned the model, I owned the runtime, storage, lifecycle, readiness, and observability around it.

Self-hosting a generative model is not just replacing an API call with a container. It means the model is part of the system, with the same deployment and reliability responsibilities as the rest of the application.

And once all of that is in place, the model just does its job:

Self-hosted SDXL output: a photorealistic mountain lake at sunset with pine trees, still water reflections, and mountains in the background