Status - MVP first-run tutorial for #11728. This is the linear PoC path for an external operator or fresh agent. It proves the adoption ladder without requiring private maintainer knowledge. Keep deeper rationale in the Deployment Cookbook and the rest of this guide tree.
At the end of this tutorial, a fresh operator has:
- run a Dockerized remote-MCP healthcheck demo;
- connected to Memory Core and Knowledge Base over StreamableHTTP;
- queried Neo-shared KB content;
- pushed one tenant repo payload through the KB repo-push MCP facade and reviewed the production repo-push client form;
- emitted one client-side
parsed-chunk-v1parser payload; - exercised the bulk/backfill path for work over the MCP volume gate;
- identified the backup/redeploy handoff that must be verified before the deployment is treated as durable.
The commands below use local demo endpoints. Replace the URLs, tenant id, repo slug, and tokens with deployment values once the same path is run behind Caddy or another production ingress.
When using the optional ingress profile, ai/deploy/Caddyfile binds
tls internal to NEO_DEPLOY_HOSTNAME, defaulting to localhost. Set
NEO_DEPLOY_HOSTNAME before starting the ingress service when testing a named
host.
Run from a Neo checkout that contains the cloud deployment profile and repo-push client:
git clone https://github.com/neomjs/neo.git
cd neo
npm installDeploy images source neo independently. The
ai/deployimages build by cloning neo at a pinned ref (NEO_REF, defaultdev), so they do not require a co-located checkout — any operator or CI host can build them. This local checkout is for running the tutorial's commands and for dev iteration (--build-arg NEO_SOURCE=local, with the neo repo root as the build context). PinNEO_REFto a tag/SHA for a fully reproducible deployment.
Required local tools:
| Tool | Check | Expected output |
|---|---|---|
| Node.js 24+ | node --version |
v24... |
| npm | npm --version |
a version string |
| Docker Compose | docker compose version |
a Compose version string |
Container runtime choices:
| Runtime | Fit | Setup hint |
|---|---|---|
| Docker Desktop | macOS / Windows desktop default. | Install Docker Desktop and start it before running docker compose. |
| Docker Engine | Linux host or server default. | Install the Docker Engine and Compose plugin from your distribution or Docker packages. |
| Colima | Open-source macOS VM runtime with Docker-compatible sockets. | Install colima plus Docker CLI tooling, then start a VM before running this tutorial. |
| Podman | Docker-compatible alternative for teams already standardized on Podman. | Enable Docker-compatible Compose support and verify docker compose version. |
| OrbStack | macOS desktop runtime with Docker-compatible CLI integration. | Install OrbStack, start it, and verify docker compose version. |
All commands in this guide use Docker's canonical no-dash docker compose
form. If your runtime only exposes the legacy docker-compose binary, wire it
as a Docker CLI plugin before continuing.
For local macOS VM runtimes such as Colima, size the VM for the Agent OS stack: use roughly 16 GiB as the practical minimum for Chroma + KB + MC + Orchestrator, and 24+ GiB when loading local chat models alongside the stack.
Failure signatures:
| Signature | Meaning | Fix |
|---|---|---|
docker: command not found |
Docker is not installed in this environment. | Install Docker Desktop / Docker Engine or run the tutorial on a Docker-capable host. |
docker: unknown command: docker compose |
Docker CLI is installed, but the Compose plugin path is not wired. This is common after brew install docker docker-compose on macOS. |
mkdir -p ~/.docker/cli-plugins && ln -sf "$(brew --prefix)/bin/docker-compose" ~/.docker/cli-plugins/docker-compose |
Cannot connect to the Docker daemon |
Docker is installed but not running or not accessible. | Start Docker and verify docker info. |
npm ERR! during install |
Dependencies are missing or the registry is unavailable. | Retry after network recovery; do not continue with a partial node_modules. |
Start the day-0 fixture stack. It uses the same Dockerized topology as the integration harness: Chroma, a deterministic OpenAI-compatible provider mock that serves embeddings plus chat completions, KB, and MC.
export NEO_DAY0_PROJECT="neo-day0"
docker compose \
-p "$NEO_DAY0_PROJECT" \
-f ai/deploy/docker-compose.test.yml \
up --build -d chroma embedding-server kb-server mc-serverCreate a tiny local MCP caller that sends the trusted proxy identity header used by the fixture:
cat > /tmp/day0-call-tool.mjs <<'NODE'
import fs from 'node:fs';
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const [,, baseUrl, identity, toolName, argsFile] = process.argv;
const args = argsFile ? JSON.parse(fs.readFileSync(argsFile, 'utf8')) : {};
const transport = new StreamableHTTPClientTransport(new URL('/mcp', baseUrl), {
requestInit: {headers: {'X-PREFERRED-USERNAME': identity}}
});
const client = new Client({name: 'day0-tutorial', version: '1.0.0'}, {capabilities: {}});
try {
await client.connect(transport);
const result = await client.callTool({name: toolName, arguments: args});
const text = result.content?.find(item => item.type === 'text')?.text;
const payload = result.structuredContent || (text ? JSON.parse(text) : result);
console.log(JSON.stringify(payload, null, 2));
process.exitCode = result.isError ? 1 : 0;
} finally {
await client.close().catch(() => {});
}
NODEThe fixture publishes these local endpoints:
| Service | URL |
|---|---|
| KB MCP | http://127.0.0.1:13000/mcp |
| MC MCP | http://127.0.0.1:13001/mcp |
| Chroma heartbeat | http://127.0.0.1:18080/api/v2/heartbeat |
Call both healthchecks:
node /tmp/day0-call-tool.mjs http://127.0.0.1:13000 day0-operator healthcheck
node /tmp/day0-call-tool.mjs http://127.0.0.1:13001 day0-operator healthcheckExpected output from each command includes:
{
"status": "healthy"
}If Docker is unavailable, the host cannot execute the Docker proof. That is an environment blocker, not a Neo deployment result.
The healthcheck proves MCP transport, auth stamping, Chroma connectivity, and
provider configuration visibility. It is not by itself a full model-readiness
proof. The integration suite also exercises the same cloud profile through
test/playwright/integration/CloudProviderReadiness.integration.spec.mjs, which
calls the deployed Memory Core container against explicit mocked provider
endpoints for:
- OpenAI-compatible
/v1/embeddings - streaming OpenAI-compatible
/v1/chat/completions - the Dream/REM graph-mutator provider dispatch seam
For production, provider/auth/storage ownership starts at the Tier-1
ai/config.template.mjs contract. KB and MC consume that shared provider and
auth shape, while keeping server-local boundaries for ports, collection names,
SQLite paths, and MCP transport details. The two supported cloud-provider
profiles are intentionally distinct:
| Profile | Selectors | Endpoint shape | Use when |
|---|---|---|---|
| Native Ollama | NEO_MODEL_PROVIDER=ollama, NEO_EMBEDDING_PROVIDER=ollama |
Ollama /api/chat and /api/embed |
The deployment exposes a real Ollama service and you want native Ollama request semantics. |
| OpenAI-compatible fallback | NEO_MODEL_PROVIDER=openAiCompatible, NEO_EMBEDDING_PROVIDER=openAiCompatible |
/v1/chat/completions and /v1/embeddings |
The deployment runs MLX, LM Studio, llama.cpp, managed OpenAI-compatible infrastructure, or an Ollama endpoint that intentionally exposes the OpenAI-compatible surface. |
Do not mix the selectors accidentally. A native Ollama deployment should prove
the native routes; an OpenAI-compatible deployment should prove both /v1
routes. Container health or embeddings-only success is not enough to hand a
cloud deployment to agents.
For the production profile, the same readiness idea is encoded in
ai/deploy/docker-compose.yml: Chroma
must be healthy before KB and MC start. The day-0 proof above is the operator
check that the remote KB and MC MCP endpoints themselves answer before the
deployment is handed to agents.
Use the Memory Core endpoint from Milestone 0 or your deployed public URL.
export NEO_MC_MCP_BASE_URL="http://127.0.0.1:13001"
export NEO_OPERATOR_IDENTITY="day0-operator"Call the healthcheck tool using the same identity header your deployment
trusts:
node /tmp/day0-call-tool.mjs \
"$NEO_MC_MCP_BASE_URL" \
"$NEO_OPERATOR_IDENTITY" \
healthcheckExpected payload fields:
{
"status": "healthy",
"database": {
"topology": {
"mode": "unified",
"resolvedVia": "engines.chroma"
}
},
"providers": {
"auth": {
"configured": "proxy-header"
}
}
}Then write and query one memory from the same identity:
cat > /tmp/day0-memory.json <<'JSON'
{
"prompt": "day-0 tutorial smoke",
"thought": "Memory Core write path reached over remote MCP",
"response": "memory-core-ok",
"agent": "day0-operator"
}
JSON
node /tmp/day0-call-tool.mjs \
"$NEO_MC_MCP_BASE_URL" \
"$NEO_OPERATOR_IDENTITY" \
add_memory \
/tmp/day0-memory.jsonExpected write result includes a memory id. A follow-up query should return that memory for the same tenant identity:
cat > /tmp/day0-memory-query.json <<'JSON'
{
"query": "day-0 tutorial smoke",
"nResults": 3
}
JSON
node /tmp/day0-call-tool.mjs \
"$NEO_MC_MCP_BASE_URL" \
"$NEO_OPERATOR_IDENTITY" \
query_raw_memories \
/tmp/day0-memory-query.jsonExpected result: at least one returned memory includes memory-core-ok.
Failure signatures:
| Signature | Meaning | Fix |
|---|---|---|
401 Unauthorized: Missing proxy identity header |
NEO_AUTH_TRUST_PROXY_IDENTITY=true but no trusted identity header reached the MCP server. |
Verify ingress header stripping/injection and use the same auth path as real agents. |
database.connected: false |
MC cannot reach Chroma or SQLite graph storage. | Check NEO_CHROMA_HOST, NEO_CHROMA_PORT, NEO_MEMORY_DB_PATH, and container networking. |
Missing database.topology.mode: "unified" |
The deployment is not using the supported unified Chroma topology. | Re-check the compose/profile config against ADR 0003. |
Use the KB endpoint from Milestone 0 or your deployed public URL.
export NEO_KB_MCP_BASE_URL="http://127.0.0.1:13000"
export NEO_KB_MCP_URL="http://127.0.0.1:13000/mcp"Call the KB healthcheck tool:
node /tmp/day0-call-tool.mjs \
"$NEO_KB_MCP_BASE_URL" \
"$NEO_OPERATOR_IDENTITY" \
healthcheckExpected payload fields:
{
"status": "healthy",
"database": {
"connection": {
"connected": true,
"collections": {
"knowledgeBase": {
"exists": true
}
}
}
},
"features": {
"embedding": true
}
}Seed the Neo-shared corpus once inside the KB container before querying it:
docker compose \
-p "$NEO_DAY0_PROJECT" \
-f ai/deploy/docker-compose.test.yml \
exec -T kb-server \
npm run ai:sync-kbFor a production compose stack, use the same one-shot form against the deployed
kb-server. This is an operator-triggered seed/import, not the periodic cloud
orchestrator kbSync lane; NEO_ORCHESTRATOR_KB_SYNC_ENABLED remains false
per ADR 0014.
Then query Neo-shared content:
cat > /tmp/day0-kb-query.json <<'JSON'
{
"query": "cloud deployment tenant ingestion model",
"type": "guide",
"limit": 3
}
JSON
node /tmp/day0-call-tool.mjs \
"$NEO_KB_MCP_BASE_URL" \
"$NEO_OPERATOR_IDENTITY" \
ask_knowledge_base \
/tmp/day0-kb-query.jsonExpected result: a synthesized answer with references that include
learn/agentos/cloud-deployment/TenantIngestionModel.md or a neighboring
cloud-deployment guide.
Failure signatures:
| Signature | Meaning | Fix |
|---|---|---|
collection does not exist |
The KB collection was not initialized. | Run the one-shot kb-server npm run ai:sync-kb command above, or inspect KB startup logs. |
| Empty answer with healthy DB | The curated Neo content is not indexed. | Verify the image contains the Neo source trees and re-run the one-shot Neo-shared KB sync before tenant onboarding. |
Tool not found: ask_knowledge_base |
The endpoint is not the KB MCP server. | Verify /kb/mcp routing and NEO_PUBLIC_URL. |
Pick a secret-free tenant tuple:
export NEO_KB_TENANT_ID="client-org"
export NEO_KB_REPO_SLUG="neomjs/create-app"
export NEO_KB_INGEST_TOKEN="<repo-push-automation-token>"Create a small content-bearing envelope:
cat > /tmp/day0-envelope.json <<'JSON'
{
"tenantId": "client-org",
"repoSlug": "neomjs/create-app",
"files": [
{
"sourcePath": "docs/hello.md",
"content": "# Hello from the tenant repo\n\nThis chunk proves day-0 tenant ingestion."
}
],
"deleted": [],
"baseRevision": "day0-base",
"headRevision": "day0-head"
}
JSONSubmit it to the local fixture through the MCP tool helper. The helper sends the trusted tenant identity header, so the server can stamp the tenant context:
node /tmp/day0-call-tool.mjs \
"$NEO_KB_MCP_BASE_URL" \
"$NEO_KB_TENANT_ID" \
ingest_source_files \
/tmp/day0-envelope.jsonProduction repo-push automation should use the deployable client and token flow from Hook Wiring:
npm run ai:kb-push-client -- \
--url "https://agent-os.example.com/kb/mcp" \
--tenant-id "$NEO_KB_TENANT_ID" \
--repo-slug "$NEO_KB_REPO_SLUG" \
--from-file /tmp/day0-envelope.jsonExpected output:
{
"ingested": 1,
"errors": []
}Then query the tenant phrase:
cat > /tmp/day0-tenant-query.json <<'JSON'
{
"query": "Hello from the tenant repo day-0 tenant ingestion",
"type": "all",
"limit": 3
}
JSON
node /tmp/day0-call-tool.mjs \
"$NEO_KB_MCP_BASE_URL" \
"$NEO_KB_TENANT_ID" \
ask_knowledge_base \
/tmp/day0-tenant-query.jsonExpected result: the answer or references include docs/hello.md and the tenant
content. If the same query is run as a different tenant, private tenant content
must not leak.
Failure signatures:
| Signature | Meaning | Fix |
|---|---|---|
HTTP 401 / Unauthorized |
Missing, expired, or wrong-audience repo-push token. | Refresh the automation identity token and verify the audience/resource matches the public KB MCP URL. |
KB_INGEST_TENANT_MISMATCH |
Payload tenant claim conflicts with authenticated tenant context. | Fix the token/identity mapping or remove the client-side tenant claim. |
KB_INGEST_VOLUME_EXCEEDED |
The payload exceeded mcpSyncMaxChunks. |
Split the envelope or use Milestone 5's bulk path. |
errors is non-empty |
One or more files failed validation or parsing. | Fail the hook/CI job and surface the structured {code, message} entries. |
Client-side parsing is the default for tenant-owned or non-JS formats. Use the minimal external workspace as the executable shape:
cd examples/cloud-deployment/minimal-external-workspace
npm installFor the worked .proto file, emit one parsed-chunk-v1 record on the client
side and submit it as an ingestion envelope:
node - <<'NODE' > /tmp/day0-proto-envelope.json
const fs = require('fs');
const content = fs.readFileSync('proto/example.proto', 'utf8');
process.stdout.write(JSON.stringify({
tenantId: 'client-org',
repoSlug: 'neomjs/create-app',
files: [{
sourcePath: 'proto/example.proto',
parsedChunks: [{
schemaVersion: '1.0.0',
tenantId: 'client-org',
repoSlug: 'neomjs/create-app',
rootKind: 'external-source',
sourcePath: 'proto/example.proto',
content,
hashInputs: ['kind', 'name', 'content', 'sourcePath', 'parserId', 'parserVersion'],
parserId: 'proto-client-day0',
parserVersion: '1.0.0',
kind: 'schema',
name: 'example.proto'
}]
}]
}));
NODE
cd ../../..
node /tmp/day0-call-tool.mjs \
"$NEO_KB_MCP_BASE_URL" \
"$NEO_KB_TENANT_ID" \
ingest_source_files \
/tmp/day0-proto-envelope.jsonExpected output:
{
"ingested": 1,
"errors": []
}Failure signatures:
| Signature | Meaning | Fix |
|---|---|---|
KB_PARSED_CHUNK_INVALID |
The record violates parsed-chunk-v1. |
Validate against parsed-chunk-v1.schema.json; remove unknown top-level fields. |
KB_PARSED_CHUNK_EMBEDDING_REJECTED |
The client sent an embedding field. |
Remove embeddings; the KB server owns embedding generation. |
| Parser output accepted but query misses it | Tenant identity, visibility, or repo slug does not match the query context. | Query as the same authenticated tenant and verify repoSlug. |
The MCP path is for bounded push deltas. To prove the operator response to the volume gate, first know the failure:
{
"code": "KB_INGEST_VOLUME_EXCEEDED",
"batchSize": 312,
"threshold": 50
}The response is either split into smaller repo-push envelopes or run a deployment-host bulk import. In the local fixture, run the bulk CLI inside the KB container so it uses the same container-local embedding and Chroma endpoints:
node - <<'NODE' > /tmp/day0-bulk.jsonl
for (let i = 0; i < 3; i++) {
process.stdout.write(JSON.stringify({
schemaVersion: '1.0.0',
tenantId: 'client-org',
repoSlug: 'neomjs/create-app',
rootKind: 'external-source',
sourcePath: `bulk/doc-${i}.md`,
content: `# Bulk doc ${i}\n\nBulk import smoke record ${i}.`,
hashInputs: ['kind', 'name', 'content', 'sourcePath', 'parserId', 'parserVersion'],
parserId: 'bulk-day0',
parserVersion: '1.0.0',
kind: 'doc-section',
name: `Bulk doc ${i}`
}) + '\n');
}
NODE
cat /tmp/day0-bulk.jsonl | docker compose \
-p "$NEO_DAY0_PROJECT" \
-f ai/deploy/docker-compose.test.yml \
exec -T kb-server \
node ./buildScripts/ai/ingestTenant.mjs client-org --from-stdin --batch-size 2Expected output:
{
"tenantId": "client-org",
"ingested": 3,
"errors": []
}Failure signatures:
| Signature | Meaning | Fix |
|---|---|---|
Deferred: heavy-maintenance lease held by ... |
Another heavy KB job is active. | Retry after the holder completes. |
KB_INGEST_CLI_JSONL_PARSE_FAILED |
A JSONL line is malformed. | Fix the line; the CLI reports parse errors without hiding sibling records. |
| Non-zero CLI exit | One or more records failed. | Inspect the printed errors array before retrying. |
Push (Milestone 3) is the primary tenant-ingestion path — tenant repo workflow drives the publish cadence under a service-account identity, and the deployment never holds tenant-repo credentials. Pull mode is the additive alternative for tenants who want the deployment to own the ingestion cadence (orchestrator polls the tenant repo on a schedule rather than waiting on a pre-push hook).
The substrate that powers pull mode shipped via Epic #11731 — clone-trust + credential-boundary contract (TenantRepoAccessContract + GitMirror), envelope builder, scheduler lane, RawRepoSource, branchRef, file: credentialRef, Tier-1 tenantRepoMirrorRoot fallback. Read Tenant Ingestion Model for the operator decision model and Hook Wiring for the push-vs-pull selection guide.
Skip this milestone if push covers your tenant content. Pull is additive, not a prerequisite for the Day-0 operator handoff.
If pull mode applies to your deployment, configure one tenant repo as a smoke. The pull-mode polling config resolves through the same three tiers as the rest of tenant config: the kb-config:<tenantId> graph node → the kb-config.yaml bootstrap (tenants.<id>.tenantRepos) → the local config.mjs aiConfig.tenantRepos[] default. kb-config.yaml is the canonical bootstrap tier; the config.mjs overlay below remains the Tier-3 default fallback.
// In <NEO_SOURCE_DIR>/ai/mcp/server/knowledge-base/config.mjs (operator overlay — Tier-3 default):
tenantRepos: [
{
tenantId : 'client-org',
repoSlug : 'client-org/example-repo',
cloneUrl : 'https://git.example.com/client-org/example-repo.git', // clean URL; no userinfo@
credentialRef : 'env:NEO_TENANT_EXAMPLE_TOKEN', // reference-only; resolved at GIT_ASKPASS time
branchRef : 'main' // optional; defaults to 'HEAD' = remote default
}
]Set the credential at the deployment env (compose, k8s Secret, or file:/run/secrets/<name> per #12046):
export NEO_TENANT_EXAMPLE_TOKEN="<read-only-repo-access-token>"Trigger the first sync:
node ai/scripts/maintenance/syncTenantRepos.mjs --repo-slug client-org/example-repoExpected output:
{
"status": "completed",
"details": {
"repoCount": 1,
"completedCount": 1,
"repos": [{"tenantId": "client-org", "repoSlug": "client-org/example-repo", "status": "active", "lastIngestedRev": "<sha>"}]
}
}Verify the chunks landed:
node /tmp/day0-call-tool.mjs \
"$NEO_KB_MCP_BASE_URL" \
"client-org" \
ask_knowledge_base \
/tmp/day0-tenant-query.json # re-use the query envelope from Milestone 3Failure signatures:
| Signature | Meaning | Fix |
|---|---|---|
KB_GITMIRROR_CLONE_FAILED |
Clone subprocess failed (network, auth, missing repo, or — Day-0 — missing git binary in older deploy images per #12036). |
Verify read_repository scope on the token; verify deploy image is built from current ai/deploy/Dockerfile (post-#12037 ships git). |
KB_TENANT_REPO_CREDENTIAL_REF_REQUIRED / KB_GITMIRROR_CREDENTIAL_REF_INVALID |
credentialRef missing or doesn't resolve at runtime. |
Confirm the env var name matches the credentialRef exactly; for file: scheme, confirm the file exists + is non-empty. |
KB_TENANT_REPO_MIRROR_ROOT_REQUIRED |
No per-repo mirrorRoot, no Tier-1 default, no env var. |
Set NEO_TENANT_REPO_MIRROR_ROOT=/app/.neo-ai-data in compose env (the canonical default, env-bound to aiConfig.orchestrator.tenantRepoMirrorRoot per #12050). |
A separate operator pattern: the deployment owns the source territory entirely — running its own full-corpus build over a repo it has on disk, not polling a tenant repo through tenantRepos[]. That's a Custom Source workflow (Source.extract + SourceRegistry), distinct from pull mode. Both can coexist in a single deployment (pull-mode for tenant-owned repos + Custom-Source for operator-owned content territories).
Expected day-0 result for this milestone is one of:
pull mode configured for <tenantId>/<repoSlug>; first sync completed at <sha>
or:
skipped — push (Milestone 3) covers our tenant content
or:
operator-owned Custom Source registered intentionally
Before handing the deployment to agents, prove the Memory Core store survives a container rebuild and that KB content can be regenerated or re-pushed.
Minimum handoff checklist:
[ ] shared-sqlite-data volume or managed graph-store path is persistent.
[ ] backup bundles write to the redeploy-safe backup mount.
[ ] after docker compose down && docker compose up --build, MC healthcheck is healthy.
[ ] the memory written in Milestone 1 can still be queried.
[ ] KB Neo-shared content is either still present or re-synced successfully.
[ ] tenant content is either still present or re-pushed by the hook/CI job.
[ ] endpoint URLs, token source, tenant id, repo slug, and known failure signatures are documented for the next agent.
Production compose persistence is documented in the Deployment Cookbook. Do not treat a demo stack that uses tmpfs or disposable test volumes as durable evidence.
Record this small state bundle before the first real agent session uses the deployment:
MC URL:
KB URL:
Auth mode: proxy-header | OIDC
Tenant id:
Repo slug:
Repo-push token source:
Last successful MC healthcheck:
Last successful KB healthcheck:
Last successful tenant push:
Backup location:
Known skipped milestones:
If any milestone is skipped, name the blocker explicitly. A future agent should never have to infer whether the deployment is incomplete, unauthenticated demo only, or intentionally deferred.
- Deployment Cookbook - deployment authority and service topology.
- Overview - cloud ingestion concepts and tenant/Neo-shared split.
- Tenant Ingestion Model - repo identity, parser dispatch, and source-family inventory.
- Hook Wiring -
ai:kb-push-client,ingest_source_files, andai:ingest-tenant. - Custom Parsers - client-side
parsed-chunk-v1parser contract. - Security - tenant stamping, spoof rejection, and parser trust.