@conjureos/pack
v0.2.0
Published
Anchor-app toolkit for ConjureOS: a local dev server (esbuild — `conj-pack dev`, no Vite) plus a packer that validates a web app and writes a .conj for the importer. A .conj is just a DEFLATE zip with a metadata sidecar — rename it to .zip to inspect.
Maintainers
Readme
@conjureos/pack
Validate and package a web app into a .conj for the ConjureOS importer.
It's for anyone who wants to hand someone an app that imports cleanly — not
just first-party anchor apps.
A .conj file is just a DEFLATE-compressed ZIP with one extra file: a
conjureos.pack.json sidecar at the root. Rename a .conj to .zip and any
unzip tool opens it. The sidecar carries the format version, packer version,
packed-at timestamp, and a normalized copy of the app's manifest so the
importer can trust the artifact on sight instead of re-walking every entry.
Why bother with the wrapper?
Dragging a raw project ZIP into ConjureOS works today, but the importer has
to (a) strip whatever node_modules / dist / .git the contributor accidentally
bundled, (b) hope the bundler doesn't choke on stray debris, and (c) guess at
a display name. .conj shifts all of that left to a one-shot package step:
- Validation runs once at package-time, not on every import.
- Dev-only directories (
node_modules,dist,.git,.vite,.DS_Store,.env*except.env.example,*.tsbuildinfo,*.log) are stripped automatically — the 70 MB fitness ZIP becomes a few hundred KB. - Size ceilings are enforced (default 10 MB, configurable).
- The format is versioned so future importer changes can refuse stale artifacts cleanly instead of trying to limp along.
CLI
No flags required. From the project root:
cd my-app
npx -p @conjureos/pack conj-pack
# wrote /path/to/my-app.conj (212483 bytes, 47 source files)That writes <package-name>.conj next to your package.json. Drag it onto
the ConjureOS Import dialog and it loads. The optional flags:
npx -p @conjureos/pack conj-pack [SOURCE_DIR] [options]
-o, --out <path> Output .conj path. Default: <pkg-name>.conj in cwd.
--max-bytes <size> Source-size limit. Accepts B/KB/MB suffix. Default: 10MB.
--strict Require params AND returns schemas on every action
(always on for apps declaring a `needs` block).
--skip-validation Bypass validation (use sparingly).
-h, --help Show help.
# exit 0 success
# exit 1 validation failed (bad/missing package.json, no entry point, too big,
# unresolvable manifest type ref, malformed needs)
# exit 2 argument / runtime errorconj-pack init — scaffold a new app
npx -p @conjureos/pack conj-pack init --name my-app # writes ./my-app
cd my-app && npm install && npm run devGenerates a working self-describing app: a conjureos block with one typed
example action (provides) and one example need (consumes), both
referencing interfaces in src/manifest-types.ts; typings for
window.__conjureos; an entry that registers the action handler and calls
actions.discover(); tsconfig + dev/pack scripts. The target directory must
be new or empty — init never overwrites existing work. npm run pack on a
fresh scaffold produces a valid .conj with zero edits.
Library
import { pack, unpack, validate } from "@conjureos/pack";
// walkSource needs node:fs, so it lives behind the Node-only subpath:
import { walkSource } from "@conjureos/pack/node";
// 1. From a Node directory tree:
const files = await walkSource("./my-app");
const result = pack(files); // throws PackValidationError on bad input
await fs.writeFile("my-app.conj", result.bytes);
// 2. From an in-memory FileMap (e.g. browser):
const v = validate(files);
if (!v.ok) { /* show v.errors to the user */ }
// 3. Inverse:
const { files, meta, formatSupported } = unpack(conjBytes);What validate() enforces
Validation mirrors what the ConjureOS importer actually rejects. Hard errors (pack fails) — these are the things that break an import:
package.jsonexists and parses.- An entry point exists at one of:
src/main.tsx,src/main.ts,src/main.jsx,src/main.js,src/index.tsx,src/index.ts,index.html. - Total uncompressed size is under
maxBytes(default 10 MB). 80% of limit emits a warning.
Everything about the conjureos block is optional — it's authoring sugar
the importer derives or re-reads itself, not an import gate:
- No
conjureosblock?displayNameis derived frompackage.jsonname(falling back to"Untitled app") andicondefaults to"fa:cube", with a warning. displayName/iconpresent but blank/malformed → derived default + warning.permissions/actions/promptSuggestionspresent but malformed → the bad field (or the single bad action) is dropped with a warning. Unknown permissions warn (the kernel ignores them).
pack() calls validate() first and throws PackValidationError on
failure (caller can inspect .validation for issues).
Action contracts (conjureos.actions)
Each action is an endpoint other apps and the AI orchestrator can invoke. It
declares a permission (actions.read / actions.write) plus:
description— required. An action with no non-empty description is dropped (with a warning): the orchestrator matches user intent against this text, so an action without one is unroutable. The description must be substantive, not just the action name restated —validate()warns when a description is very short or only echoes the action name (e.g. actionlogMealdescribed as "Log meal"). Say what it does and when to use it, so the router can tell it apart from sibling actions.params— optional JSON-Schema (recommended). The input shape the orchestrator fills. Object root; typed scalars / enums / arrays / nested objects withrequired+ bounds. When absent,validate()nudges (the model must infer the shape from prose). Recommend adescriptionon any property whose meaning or format isn't obvious from its name (dates, slugs, units, semantic enums) — e.g."date": { "type": "string", "description": "YYYY-MM-DD; defaults to today" }.returns— optional JSON-Schema (advisory). The result shape; documents the endpoint and supports future multi-step chaining. No nudge when missing.
A malformed params/returns schema is dropped with a warning (the action
still publishes off its description) — schemas never hard-fail a pack unless
the app is self-describing (it declares needs, or you pass --strict); see
the next section.
"logRecipeMeal": {
"permission": "actions.write",
"description": "Log a saved recipe as a meal by slug, copying its per-serving nutrition into the diary and marking the recipe cooked.",
"params": {
"type": "object",
"properties": {
"slug": { "type": "string", "description": "The Recipes app slug of the saved recipe." },
"servings": { "type": "number", "minimum": 0.1, "maximum": 20, "description": "Defaults to 1." }
},
"required": ["slug"]
},
"returns": { "type": "object", "properties": { "id": { "type": "string" }, "logged": { "type": "boolean" } } }
}Self-describing apps (needs & provides)
Phase 45: an app's conjureos block describes both sides of its data
contracts, and the platform connects them at runtime.
- Provides = the app's
actions, each with typedparams+returns. - Needs = shapes the app wants to CONSUME from other apps:
"conjureos": {
"needs": [
{ "id": "recipeSource",
"description": "Saved recipes this app can pull into the meal planner.",
"type": "RecipeSource" }
],
"actions": {
"logFood": {
"permission": "actions.write",
"description": "Record a food the user ate in today's diary.",
"params": "LogFoodParams",
"returns": "LogFoodResult"
}
}
}At runtime the kernel matches a need's shape against installed actions'
returns — an exact structural match connects free; a near-miss can connect
through a user-confirmed AI field map. Apps call
window.__conjureos.actions.discover(needId), which resolves to
ProviderMatch[] ([] when nothing satisfies the need — always handle it).
Lean authoring: TS types instead of inline schemas
needs[].type and each action's params/returns accept either an
inline JSON schema (the pre-0.2 form, still valid) or a string naming an
exported TypeScript type. Resolution rules:
- The name must be a single exported
interface,typealias, enum, or class — bare name, no paths or generics. src/manifest-types.tsis searched first (the convention — put your contract types there), then every.ts/.tsxthe project tsconfigincludes (alphabetically; no tsconfig means all of them)..d.tsfiles are skipped.- Types must be self-contained within the app's source — a type that leans on an npm import won't resolve.
- JSDoc comments on properties become the schema
descriptions — write them for the orchestrator, they ship.
Supported shapes map onto the schema subset: primitives (string, number,
boolean), literal unions → enum, | null → type arrays, optional (?)
props → omitted from required, arrays, nested objects/intersections, and
Record<string, …> → open objects. Not supported (build error naming the file
and type): Date/Map/Promise/functions, tuples, unions of objects,
recursion, any/unknown. TS has no integer and no bounds — use an inline
schema when you need "type": "integer", minimum, minLength, etc.
conj-pack (and conj-pack dev, at startup) compiles the refs and emits a
fully-expanded manifest — inline schemas only — into the .conj's rewritten
package.json and sidecar. A ref that doesn't resolve is a build failure.
Building a FileMap yourself? Run the same expansion first:
import { compileManifestTypes } from "@conjureos/pack/node";
const { files, issues, changed } = await compileManifestTypes(rawFiles);Strictness (and back-compat)
Declaring a needs block opts the app into the full contract: every action
must declare both params and returns, and malformed schemas become
errors — those schemas are what the platform matches on. Legacy apps (no
needs) keep warnings-only behavior so published apps still pack; pass
--strict to get the errors without declaring needs. An uncompiled type-ref
string is always an error.
Types for app authors
@conjureos/pack re-exports the shared runtime types from
@conjureos/bridge: AppNeed, ProviderMatch, ActionParamSchema —
discover() returns Promise<ProviderMatch[]>. The conj-pack init scaffold
ships matching window.__conjureos typings.
File format
my-app.conj (deflate ZIP, .conj extension)
├── conjureos.pack.json
│ {
│ "formatVersion": 1,
│ "packerVersion": "0.0.1",
│ "packedAt": "2026-05-31T00:00:00.000Z",
│ "name": "conjureos-fitness",
│ "version": "0.2.0",
│ "description": "...",
│ "manifest": { "displayName": "Conjure Fitness", "icon": "...", ... },
│ "files": ["src/main.tsx", "src/App.tsx", ...]
│ }
├── package.json
├── src/
│ ├── main.tsx
│ └── ...
└── ... (everything else `walkSource()` kept)Format versioning
formatVersion bumps when the sidecar's required shape changes in a
non-additive way. The importer reads it and refuses unknown versions
rather than silently misinterpreting fields. Additive sidecar fields
don't bump the version — old importers ignore unknown keys.
Roadmap / out of scope (TODO)
These were deliberately left out of the initial extraction. They're tracked here so the next contributor doesn't have to rediscover them:
- [ ] Forbidden-API static scan. Block
eval, rawfetchoutside the SDK, and similar escape hatches at pack-time instead of leaving them for the runtime sandbox to catch. - [ ] Trial-build gate via
@conjureos/bundle. Packing succeeds today even if the bundler would fail to build the source — packing only checks shape, not buildability. A pre-pack trial build would shift that failure left. The importer's bundle step is still the source of truth for build correctness until then. - [ ] Signature / publisher attestation.
.conjfrom anyone is treated identically right now; there's no provenance beyond the embeddedpackerVersion. Signing belongs at the App Store layer. - [ ] Browser-side
pack(). Today onlyunpack()runs in the browser (it's purefflate+ JSON). The producer side (pack/walkSource) is Node-only becausewalkSourceneedsnode:fs. A future browser-callablepack()(fed an in-memoryFileMapinstead of a directory) would let ConjureOS itself produce.conjartifacts without shelling out to the CLI.
License
MIT
