diff --git a/README.md b/README.md
index d228285..541480c 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@ npm test
- `queryV1.query` / `queryV1.queries` - reads through query.v1: the same query bodies as `assets.query`, rows translated the same way, but the endpoint is read-only and answers with rows alone, so it is the safe place to explore a query
- `resolve.numbers` / `resolve.ref` - asset numbers and OID tokens to assets, in one request
- `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped`
+ - `attachments.list` / `info` / `get` / `add` / `put` / `remove` / `url` - files attached to an asset: metadata on the Attachment asset, bytes through attachment.img; `add` sends a small file inline (Base64, one request) and a large one in two steps (create, then upload), and a failed upload throws `AttachmentUploadError` naming the empty attachment it left
+ - `embeddedImages.upload` / `url` / `normalize` - images in rich text: upload returns the `
` URL; `normalize` rewrites `embedded.img` references to this instance's URL. They cannot be deleted
- `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. Undo (`batch.reversible`) reactivates a closed asset before deleting it, and a second command on one asset is sent as a second guarded pass. `confirm` sees the touched assets' names; aliased updates/executes are sent as written with `guard: false`; a failure after the send throws `BatchRecordError` carrying the receipt
- `TypeCache` - one instance's loaded type definitions, shared by many clients (`new AgilityApiClient(config, { types })`): a server that builds a client per user fetches each type once
- `createFetchTransport` - configurable HTTP transport: `credentials` for same-origin cookie auth, `onUnauthorized` to re-authenticate on a 401 (resends idempotent requests once), and a per-request `signal`; `client.withSignal(signal)` ties a whole client to one
diff --git a/plans/001-sdk-adoption-campaign.md b/plans/001-sdk-adoption-campaign.md
index bd575db..8bdf4de 100644
--- a/plans/001-sdk-adoption-campaign.md
+++ b/plans/001-sdk-adoption-campaign.md
@@ -251,6 +251,16 @@ moves.
Integration tests on v1sdktesting with `sdk-integration` fixtures,
deleted by OID.
+ Verified live (2026-11-07): an Attachment create **requires** `Content`
+ (empty is allowed; absent is `Violation'Required'…'Content'`); Base64
+ `Content` in the create stores the bytes in one request; `attachment.img`
+ GET answers the bytes with their type and `Content-Disposition`, 404 when
+ missing, and POST replaces them. `EmbeddedImage.mvc/Upload` accepts token
+ auth and answers ``.
+ **An EmbeddedImage cannot be deleted** (`Unknown Operation
+ 'EmbeddedImage.Delete'`), so its live test is opt-in
+ (`SDK_TEST_EMBEDDED_IMAGES=1`) and leaves one image per run.
+
What rooms does today (survey 2026-11-07), which A7 must cover:
- **Upload** is one api/asset create with the bytes inline: `{ AssetType:
"Attachment", Asset, ContentType, Filename, Name, Content: }`
@@ -462,7 +472,7 @@ ESM" here means finishing the edges:
- [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language)
- [x] A4 slack-shaped reads — `queryV1` wire module, `client.queryV1.query`/`queries` (read-only, idempotent, same row translation as api/asset)
- [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`)
-- [ ] A7 attachments and embedded images (`attachment` wire module, `client.attachments`; survey rooms first)
+- [x] A7 attachments and embedded images — `attachment` wire module (`attachment.img` bytes, `EmbeddedImage.mvc/Upload`, URL helpers, browser-safe Base64), `client.attachments` (list/info/get/add/put/remove/url; inline up to `INLINE_LIMIT`, two-step above) and `client.embeddedImages` (upload/url/normalize)
- [ ] A6 CI + prerelease publish — CI (`.github/workflows/ci.yml`: verify on Node 20/22/24, `npm pack` artifact), git-SHA installs (`prepare`) and the dependency audit done; publish `@versionone/sdk@1.0.0-alpha.0` to npmjs with `--tag alpha` next (needs an org owner's token or trusted publisher)
- [x] G0–G3 gh-v1 migrations — versionone/gh-v1#29–#32
- [x] S1–S4 slack-bot migrations — digital-ai/k6i-agility-slack-bot#21–#24 (on `bolt-migration`)
@@ -474,5 +484,5 @@ ESM" here means finishing the edges:
- [x] C1 gh-v1 seam swap — versionone/gh-v1#33, with the vendored SDK's hashes, CI and Node 22 floor in #35
- [x] C2 gh-v1 rows translated through the schema; `isTrue`/`oidOf` gone — versionone/gh-v1#36
- [x] C3 gh-v1 resolve and batch on `client.resolve`/`client.batch` — versionone/gh-v1#37 (with the SDK fixes from #43)
-- [ ] C4 gh-v1 attachments on `client.attachments` (after A7); `raw` stays CLI-local
+- [ ] C4 gh-v1 attachments on `client.attachments` (unblocked: A7 done); `raw` stays CLI-local
- [ ] D1–D4 agility-rooms
diff --git a/src/client.ts b/src/client.ts
index ad3a2cf..519d94a 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,6 +1,7 @@
import type { ClientConfig } from "./core/clientConfig";
import { createFetchTransport } from "./core/fetchTransport";
import type { Transport } from "./core/transport";
+import * as attachments from "./services/attachments";
import * as batch from "./services/batch";
import * as guard from "./services/guard";
import * as resolve from "./services/resolve";
@@ -8,6 +9,7 @@ import { Schema, type TypeCache } from "./services/schema";
import type { Asset } from "./types/asset";
import type { Metamodel } from "./types/metamodel";
import * as assetApi from "./wire/assetApi";
+import * as attachment from "./wire/attachment";
import * as loc2 from "./wire/loc2";
import * as meta from "./wire/meta";
import * as queryV1 from "./wire/queryV1";
@@ -114,6 +116,33 @@ export class AgilityApiClient {
reversible: (receipt: Pick, options?: { force?: boolean }) => batch.reversible(this.transport, receipt, options)
};
+ /**
+ * The files attached to assets: metadata on Attachment assets, bytes
+ * through attachment.img. `add` sends a small file inline (one request)
+ * and a large one in two steps; `url` is where a browser opens one.
+ */
+ readonly attachments = {
+ list: (asset: string, options?: { page?: { size: number; start: number } }) => attachments.list(this.transport, asset, options),
+ info: (id: string | number) => attachments.info(this.transport, id),
+ get: (id: string | number) => attachments.get(this.transport, id),
+ add: (asset: string, data: attachment.Bytes, options: attachments.AddOptions) => attachments.add(this.transport, asset, data, options),
+ put: (id: string | number, data: attachment.Bytes, contentType?: string) => attachments.put(this.transport, id, data, contentType),
+ remove: (id: string | number) => attachments.remove(this.transport, id),
+ url: (id: string | number) => attachment.attachmentUrl(this.config.baseUrl, id)
+ };
+
+ /**
+ * Images embedded in an asset's rich text (Description and other long
+ * text). `upload` returns the URL to put in an
; `normalize`
+ * rewrites every embedded.img reference in some HTML to this instance's
+ * absolute URL, as Agility's own editor stores it. They cannot be deleted.
+ */
+ readonly embeddedImages = {
+ upload: (asset: string, data: attachment.Bytes, options?: { filename?: string; contentType?: string }) => attachment.uploadEmbeddedImage(this.transport, asset, data, options),
+ url: (id: string | number) => attachment.embeddedImageUrl(this.config.baseUrl, id),
+ normalize: (html: string) => attachment.normalizeEmbeddedImageUrls(html, this.config.baseUrl)
+ };
+
/** rest-1.v1: bounded reads, one asset, history. */
readonly data = {
list: (type: string, params: rest1.ReadParams) => rest1.list(this.transport, type, params),
diff --git a/src/core/fetchTransport.ts b/src/core/fetchTransport.ts
index fbab5f0..d020ae4 100644
--- a/src/core/fetchTransport.ts
+++ b/src/core/fetchTransport.ts
@@ -89,6 +89,8 @@ function encodeBody(body: RequestBody | undefined, contentType: string | undefin
}
// A form is handed to fetch as is: it writes the multipart boundary itself.
if (typeof FormData !== "undefined" && body instanceof FormData) return { body };
+ // A Blob (a browser File, say) is bytes with a type of its own.
+ if (typeof Blob !== "undefined" && body instanceof Blob) return { body, contentType: contentType ?? (body.type || "application/octet-stream") };
return { body: JSON.stringify(body), contentType: contentType ?? "application/json" };
}
diff --git a/src/core/transport.ts b/src/core/transport.ts
index 523a5a4..aa68361 100644
--- a/src/core/transport.ts
+++ b/src/core/transport.ts
@@ -1,7 +1,7 @@
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
-/** A JSON value, a text body (YAML for api/asset, say), bytes (attachments), or a multipart form. */
-export type RequestBody = unknown | string | Uint8Array | ArrayBuffer | FormData;
+/** A JSON value, a text body (YAML for api/asset, say), bytes or a Blob (attachments), or a multipart form. */
+export type RequestBody = unknown | string | Uint8Array | ArrayBuffer | Blob | FormData;
/**
* How to read the response: "auto" parses JSON when the server says JSON and
diff --git a/src/index.ts b/src/index.ts
index 504b76f..01022d6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,7 +15,8 @@ export type { AssetType, AttributeDefinition, AttributeLookup, AttributeType, At
export type * from "./types/assets/index";
// The wire layer: one module per endpoint.
-export { assetApi, rest1, meta, loc2, queryV1 } from "./wire";
+export { assetApi, rest1, meta, loc2, queryV1, attachment } from "./wire";
+export type { Bytes, EmbeddedImage } from "./wire/attachment";
export * as select from "./wire/select";
export type { AssetApiResponse, Command, CommandBody, CommandFailureDetail, Outcome, CommandValue, CreateBody, ExecuteBody, QueryBody, UpdateBody, ReadOptions, RowContext, SendOptions } from "./wire/assetApi";
export { CommandFailureError } from "./wire/assetApi";
@@ -24,6 +25,8 @@ export type { Localizations, LocalizeOptions } from "./wire/loc2";
// Services: what to do.
export { Schema, TypeCache } from "./services/schema";
+export { AttachmentUploadError, INLINE_LIMIT } from "./services/attachments";
+export type { AddOptions as AttachmentAddOptions, AttachmentInfo } from "./services/attachments";
export { CHUNK as RESOLVE_CHUNK, NUMBER_ROOTS, isAssetNumber, resolveNumbers, resolveRef } from "./services/resolve";
export type { Resolution, ResolvedAsset } from "./services/resolve";
export { UNGUARDABLE_TYPES, UnguardableError, guard, sendGuarded, stampsOf, touchedBy, unguardable } from "./services/guard";
diff --git a/src/services/attachments.ts b/src/services/attachments.ts
new file mode 100644
index 0000000..3cd7b0e
--- /dev/null
+++ b/src/services/attachments.ts
@@ -0,0 +1,112 @@
+/**
+ * The attachments service: the files attached to an asset, as part of
+ * changing that asset like any other attribute. The metadata lives on an
+ * Attachment asset (api/asset); the bytes live behind attachment.img
+ * (wire/attachment). Consumers never call either directly.
+ *
+ * Two ways in for the bytes, chosen by size:
+ * - inline: one api/asset create with Content as Base64. One request,
+ * what agility-rooms does; the JSON is a third larger than the file.
+ * - two-step, as the docs prescribe for anything not tiny: create the
+ * Attachment with empty Content, then POST the raw bytes. A failed
+ * upload leaves an empty Attachment, which is reported, not hidden.
+ *
+ * Listing never selects Content: across many attachments that would Base64
+ * every file into one response. Reading one file's bytes goes through
+ * attachment.img.
+ */
+import { ApiError } from "../core/errors";
+import type { Transport } from "../core/transport";
+import type { Oid } from "../types/asset";
+import { momentless } from "../types/oid";
+import * as assetApi from "../wire/assetApi";
+import { getBytes, numericId, putBytes, toBase64, toUint8Array, type Bytes } from "../wire/attachment";
+
+/** Files up to this size go inline by default; larger ones in two steps. */
+export const INLINE_LIMIT = 1_000_000;
+
+export interface AttachmentInfo {
+ oid: Oid;
+ /** The numeric id attachment.img addresses it by. */
+ id: string;
+ name: string | null;
+ filename: string | null;
+ contentType: string | null;
+ description: string | null;
+ createDate: string | null;
+ createdBy: string | null;
+}
+
+type Row = { _oid: string; Name?: string | null; Filename?: string | null; ContentType?: string | null; Description?: string | null; CreateDate?: string | null; "CreatedBy.Name"?: string | null };
+const SELECT = ["Name", "Filename", "ContentType", "Description", "CreateDate", "CreatedBy.Name"];
+const infoOf = (r: Row): AttachmentInfo => {
+ const oid = momentless(r._oid);
+ return { oid, id: numericId(oid), name: r.Name ?? null, filename: r.Filename ?? null, contentType: r.ContentType ?? null, description: r.Description ?? null, createDate: r.CreateDate ?? null, createdBy: r["CreatedBy.Name"] ?? null };
+};
+
+/** The files attached to an asset, newest first, metadata only. */
+export async function list(transport: Transport, asset: Oid, options: { page?: { size: number; start: number } } = {}): Promise {
+ const rows = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["Asset=$asset"], with: { $asset: momentless(asset) }, sort: ["-CreateDate"], page: options.page ?? { size: 50, start: 0 } });
+ return rows.map((r) => infoOf(r as unknown as Row));
+}
+
+/** One attachment's metadata, or null when there is none with that id. */
+export async function info(transport: Transport, id: Oid | string | number): Promise {
+ const [row] = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["ID=$id"], with: { $id: `Attachment:${numericId(id)}` }, page: { size: 1, start: 0 } });
+ return row ? infoOf(row as unknown as Row) : null;
+}
+
+/** One attachment: its metadata and its bytes. */
+export async function get(transport: Transport, id: Oid | string | number): Promise {
+ const meta = await info(transport, id);
+ if (!meta) throw new ApiError(`attachment ${numericId(id)}: not found`, 404, "NOT_FOUND");
+ return { ...meta, bytes: await getBytes(transport, meta.id) };
+}
+
+export interface AddOptions {
+ filename: string;
+ /** Defaults to the Blob's type, then application/octet-stream. */
+ contentType?: string;
+ /** Defaults to the filename. */
+ name?: string;
+ description?: string;
+ /** Inline (one request) up to this many bytes, two-step above. Default INLINE_LIMIT. */
+ inlineLimit?: number;
+}
+
+/** An Attachment was created but its bytes could not be uploaded: it exists, empty. Remove it or upload again with `put`. */
+export class AttachmentUploadError extends Error {
+ constructor(readonly attachment: AttachmentInfo, cause: unknown) {
+ super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
+ this.name = "AttachmentUploadError";
+ }
+}
+
+/** Attach a file to an asset. Inline up to `inlineLimit` bytes, two-step above it. */
+export async function add(transport: Transport, asset: Oid, data: Bytes, options: AddOptions): Promise {
+ const bytes = await toUint8Array(data);
+ const contentType = options.contentType ?? (typeof Blob !== "undefined" && data instanceof Blob && data.type ? data.type : "application/octet-stream");
+ const inline = bytes.length <= (options.inlineLimit ?? INLINE_LIMIT);
+ const create: assetApi.CreateBody = { AssetType: "Attachment", Asset: momentless(asset), Name: options.name ?? options.filename, Filename: options.filename, ContentType: contentType, Content: inline ? toBase64(bytes) : "" };
+ if (options.description) create.Description = options.description;
+ const oid = assetApi.outcomeOf(await assetApi.send(transport, [create], { throwOnFailure: true })).created[0]!;
+ const created: AttachmentInfo = { oid, id: numericId(oid), name: String(create.Name), filename: options.filename, contentType, description: options.description ?? null, createDate: null, createdBy: null };
+ if (!inline) {
+ try {
+ await putBytes(transport, created.id, bytes, contentType);
+ } catch (error) {
+ throw new AttachmentUploadError(created, error);
+ }
+ }
+ return created;
+}
+
+/** Replace an attachment's bytes. */
+export async function put(transport: Transport, id: Oid | string | number, data: Bytes, contentType?: string): Promise {
+ await putBytes(transport, id, data, contentType);
+}
+
+/** Delete an attachment. */
+export async function remove(transport: Transport, id: Oid | string | number): Promise {
+ await assetApi.send(transport, [{ from: `Attachment:${numericId(id)}`, execute: "Delete" }], { throwOnFailure: true });
+}
diff --git a/src/wire/attachment.ts b/src/wire/attachment.ts
new file mode 100644
index 0000000..60de955
--- /dev/null
+++ b/src/wire/attachment.ts
@@ -0,0 +1,132 @@
+/**
+ * The attachment wire module: the files an asset carries. Two endpoints and
+ * one MVC route, none of them the data API:
+ *
+ * attachment.img/ GET the bytes; POST bytes to replace them
+ * embedded.img/ GET an image embedded in rich text
+ * EmbeddedImage.mvc/Upload POST multipart { image, oidToken } → an EmbeddedImage
+ *
+ * Addressed by the numeric id, not the OID token — the one place in the API
+ * that does. Bytes travel as Uint8Array, ArrayBuffer or Blob, so this works
+ * in Node and the browser alike (no Buffer).
+ *
+ * Verified against the API-testing instance (2026-11-07):
+ * - GET attachment.img/ answers the bytes with the Attachment's
+ * ContentType and `Content-Disposition: inline; filename="…"`; 404 for
+ * an id that does not exist. POST raw bytes (any content type) answers
+ * 200 with an empty body and replaces them.
+ * - An Attachment create requires Content (empty is allowed, absent is
+ * Violation'Required'…'Content'); Base64 Content in the create stores
+ * the bytes in one request, and selecting Content reads them back as
+ * Base64.
+ * - EmbeddedImage.mvc/Upload takes token auth and answers text/html:
+ * ``.
+ * An EmbeddedImage cannot be deleted (no Delete operation).
+ */
+import { ApiError } from "../core/errors";
+import type { Transport } from "../core/transport";
+import type { Oid } from "../types/asset";
+
+/** Bytes as a caller has them: a Uint8Array, an ArrayBuffer, or a Blob (a browser File). */
+export type Bytes = Uint8Array | ArrayBuffer | Blob;
+
+/** Bytes as a Uint8Array, whatever they came as. */
+export async function toUint8Array(data: Bytes): Promise {
+ if (data instanceof Uint8Array) return data;
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
+ return new Uint8Array(await data.arrayBuffer());
+}
+
+/** Base64 without Buffer, in chunks so a large file does not overflow the call stack. */
+export function toBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
+ return btoa(binary);
+}
+
+export function fromBase64(text: string): Uint8Array {
+ const binary = atob(text);
+ const out = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
+ return out;
+}
+
+/** The numeric id from "Attachment:5678", "Attachment:5678:999" (a moment) or "5678". */
+export function numericId(spec: Oid | string | number): string {
+ const m = /^(?:[A-Za-z]+:)?(\d+)(?::\d+)?$/.exec(String(spec).trim());
+ if (!m) throw new TypeError(`"${spec}" is not an asset id (5678, or an OID token such as Attachment:5678)`);
+ return m[1]!;
+}
+
+const join = (baseUrl: string, path: string): string => `${baseUrl.replace(/\/+$/, "")}/${path}`;
+
+/** Where a browser opens or downloads an attachment's bytes. */
+export const attachmentUrl = (baseUrl: string, id: Oid | string | number): string => join(baseUrl, `attachment.img/${numericId(id)}`);
+/** Where an embedded image is served from. */
+export const embeddedImageUrl = (baseUrl: string, id: Oid | string | number): string => join(baseUrl, `embedded.img/${numericId(id)}`);
+
+/**
+ * Rich text with every `embedded.img/` image reference rewritten to the
+ * instance's absolute URL — what Agility's own editor stores — whatever
+ * host, path or quoting the reference had before.
+ */
+export function normalizeEmbeddedImageUrls(html: string, baseUrl: string): string {
+ return html.replace(/(\bsrc\s*=\s*)(["'])([^"']*?embedded\.img\/(\d+)[^"']*)\2/gi, (_m, attr: string, quote: string, _url: string, id: string) => `${attr}${quote}${embeddedImageUrl(baseUrl, id)}${quote}`);
+}
+
+/** The endpoint's statuses, named: 404 missing, 413 larger than the server's maximum request length. */
+function named(error: unknown, what: string): unknown {
+ if (!(error instanceof ApiError)) return error;
+ if (error.status === 404) return new ApiError(`${what}: not found`, 404, "NOT_FOUND", error.details);
+ if (error.status === 413) return new ApiError(`${what}: larger than the server's maximum request length`, 413, "TOO_LARGE", error.details);
+ return error;
+}
+
+/** An attachment's bytes. */
+export async function getBytes(transport: Transport, id: Oid | string | number): Promise {
+ const n = numericId(id);
+ try {
+ return await transport.request({ method: "GET", path: `/attachment.img/${n}`, responseType: "bytes" });
+ } catch (error) {
+ throw named(error, `attachment ${n}`);
+ }
+}
+
+/** Replace an attachment's bytes. The Attachment asset (name, filename, content type) is unchanged. */
+export async function putBytes(transport: Transport, id: Oid | string | number, data: Bytes, contentType = "application/octet-stream"): Promise {
+ const n = numericId(id);
+ const body = data instanceof Blob ? data : await toUint8Array(data);
+ try {
+ await transport.request({ method: "POST", path: `/attachment.img/${n}`, body, contentType: data instanceof Blob && data.type ? data.type : contentType, responseType: "text" });
+ } catch (error) {
+ throw named(error, `attachment ${n}`);
+ }
+}
+
+export interface EmbeddedImage { oid: Oid; id: string; url: string }
+
+/**
+ * Upload an image to embed in an asset's rich text. The answer is the
+ * image's URL — put it in an `
` — and its EmbeddedImage OID.
+ */
+export async function uploadEmbeddedImage(transport: Transport, asset: Oid, data: Bytes, options: { filename?: string; contentType?: string } = {}): Promise {
+ const blob = data instanceof Blob ? data : new Blob([(await toUint8Array(data)) as Uint8Array], { type: options.contentType ?? "application/octet-stream" });
+ const form = new FormData();
+ form.append("image", blob, options.filename ?? "image");
+ form.append("oidToken", asset);
+ let html: string;
+ try {
+ html = await transport.request({ method: "POST", path: "/EmbeddedImage.mvc/Upload", body: form, responseType: "text" });
+ } catch (error) {
+ throw named(error, `embedded image for ${asset}`);
+ }
+ const inner = /