Namespaces
The client exposes one property per resource, plus top-level search and health:
client.items — item CRUD, transitions, versions, stats
client.metadata — tags, metadata merge operations
client.extensions — namespaced sidecar data
client.edges — edges and custom edge types
client.blobs — upload, download, URLs
client.types — type schema listing and registration
client.keys — API key management (admin)
client.webhooks — webhook registration and deliveries
client.search(query:filters:) — full-text search
client.health() — server reachability check
Items
let note = try await client.items.create(
CreateItemInput(
type: "core.note",
properties: ["body": .string("Hello"), "title": .string("First note")],
tier: .library,
tags: ["intro"]
)
)
let fetched = try await client.items.get(id: note.id)
let page = try await client.items.list(
filters: ListFilters(type: "core.note", state: .active, limit: 50)
)
// page.data: [Item], page.cursor: String?, page.hasMore: Bool
try await client.items.update(
id: note.id,
properties: ["body": .string("Updated")],
options: UpdateOptions(version: note.version)
)
try await client.items.transition(id: note.id, to: "archived")
try await client.items.delete(id: note.id) // → trashed
try await client.items.restore(id: note.id) // trashed → active
items.list returns PaginatedResult<Item> with data, cursor, hasMore. Two AsyncSequence helpers walk every page automatically — client.items.all(filters:) yields Item, and client.items.allWithMetadata(filters:) yields ItemWithMetadata pairs for views that render tags or favorite flags alongside the item.
for try await item in client.items.all(filters: ListFilters(type: "core.note")) {
// processed page-by-page; no manual cursor handling
}
Edges
let edge = try await client.edges.create(
source: note.id,
target: person.id,
edgeType: "about"
)
let outbound = try await client.edges.listFromSource(sourceId: note.id)
let inbound = try await client.edges.listToTarget(targetId: person.id, edgeType: "about")
// Global tenant-scoped listing across all items, by edge type.
let allReplies = try await client.edges.list(edgeType: "in-thread")
try await client.edges.delete(id: edge.id)
Batched backrefs
edges.listToTargets(targetIds:edgeType:limit:) resolves inbound edges for many targets in one call:
let backrefs = try await client.edges.listToTargets(
targetIds: items.map(\.id),
edgeType: "in-thread"
)
// backrefs: [String: [Edge]] — keyed by target id
backrefs["msg-1"]?.count // reply count for msg-1
In synced and pure-local modes this is one SQL query against the local store. In remote mode it fans out via a bounded TaskGroup; cap concurrency via ClientConfiguration.maxBackrefBatchConcurrency (default 8). Empty targetIds short-circuits to [:], duplicate ids are deduplicated, and unknown ids map to an empty array.
Atomic item + edges
let highlight = try await client.items.create(
CreateItemInput(
type: "core.highlight",
properties: [
"text": .string("important passage"),
"locator_type": .string("offset"),
"start_location": .int(123),
"end_location": .int(189)
],
edges: [
CreateItemEdge(edgeType: "references", direction: .outbound, otherId: documentId)
]
)
)
Item and edges land in one transaction.
Custom edge types
Mirrors the TypeScript SDK’s client.edges.types.*. Admin-gated server-side.
try await client.edges.edgeTypes.create(
CreateEdgeTypeInput(
id: "team.commented-on",
cardinality: .manyToMany,
sourceTypeConstraints: ["core.note"],
targetTypeConstraints: ["core.note"]
)
)
let types = try await client.edges.edgeTypes.list()
try await client.edges.edgeTypes.delete(id: "team.commented-on")
Bulk writes and chunked iteration
Admin-only bulk surfaces match the bulk operations shape. Pure-local clients iterate through the local store and return per-record outcomes; synced clients enqueue a single replay record; network clients round-trip straight through.
// Items
let items = try await client.items.bulk(BulkInput(items: payload))
// Edges — sibling endpoint for graph-half migrations
let edges = try await client.edges.bulk(BulkEdgeInput(edges: edgePayload))
For inputs larger than the server’s 5000 per-call cap, the Swift SDK provides client-side chunking helpers — items.bulkAll and edges.bulkAll. Both clamp batchSize to [1, 5000], aggregate per-record outcomes with absolute indices preserved, and accept an optional progressHandler for UI wiring.
let result = try await client.edges.bulkAll(
largeEdgeSet,
batchSize: 500,
progressHandler: { completed, total in
await progressModel.update(completed: completed, total: total)
}
)
Cross-batch atomicity does not hold — each batch’s atomic: true guarantee stops at its own server transaction. Callers needing strict all-or-nothing semantics across a run larger than one batch reconcile failures out-of-band.
items.bulkAction(_:) is the filter-driven sibling of items.bulk — instead of taking a list of records, it applies one action across every item matching a BulkActionFilter. Use it to archive, trash, restore, purge, retag, flip tier, patch properties, or bump updated_at across a server-side slice without fetching ids first.
let result = try await client.items.bulkAction(
.transition(
filter: BulkActionFilter(type: "core.note", state: .active, tags: ["stale"]),
state: .archived,
options: BulkActionOptions(dryRun: true, maxItems: 1000)
)
)
// result.matched / .succeeded / .errored / .dryRun
BulkActionInput is a discriminated enum — .transition, .purge, .updateTags, .updateLibrary, .updateProperties, .updateTimestamp. Purge requires options.confirm == "PURGE". Synced clients queue the action for replay; pure-local clients apply it in a single local-store transaction.
In remote mode, bulkAction(_:options:) polls the server-side job until it reaches a terminal status — completed, failed, or cancelled. Surface progress to the UI by passing BulkActionPollOptions(onProgress:):
let result = try await client.items.bulkAction(
.purge(filter: BulkActionFilter(type: "core.scratch"), options: .init(confirm: "PURGE")),
options: BulkActionPollOptions(onProgress: { job in
Task { await viewModel.update(processed: job.processed, of: job.matched) }
})
)
For explicit-control flows (returning a job id to an LLM, driving polling from a long-lived UI), bulkActionAsync(_:) POSTs and returns the BulkActionJob envelope without polling; bulkActionStatus(jobId:) and bulkActionCancel(jobId:) wrap the GET / DELETE endpoints. Cancellation throws BulkJobCancelledError; a failed job throws BulkJobFailedError carrying the worker’s reason.
let metadata = try await client.metadata.get(itemId: note.id)
try await client.metadata.addTags(itemId: note.id, tags: ["urgent"])
try await client.metadata.removeTag(itemId: note.id, tag: "urgent")
try await client.metadata.set(itemId: note.id, input: MetadataInput(tags: ["work"]))
try await client.metadata.merge(itemId: note.id, input: MetadataInput(tags: ["q2"]))
// Distinct tags across the tenant, with usage counts.
let tags = try await client.metadata.listTags()
metadata.listTags() is mode-aware. In synced and pure-local modes the call fetches metadata rows whose parent item is not trashed and buckets the tags in Swift; in remote mode it hits GET /metadata/tags. Either way the return shape is [TagWithCount] ordered count DESC, tag ASC. Local-mode reads short-circuit the network round-trip.
The tier field sits on the item itself, not in metadata. Set it on create via CreateItemInput.tier, or flip it post-creation via items.update(id:properties:options:) with UpdateOptions(tier: .library). Tier-only updates don’t conflict — tier is metadata-axis, last-writer-wins. system.* items have no tier; the field is optional in the SDK shape (Tier?).
Two paths, depending on client mode:
-
Synced / pure-local mode. Apps render lists of items with their tags or favorite flag — a timeline, a grid, a detail view. Use the SDK’s reactive layer rather than polling. In Swift,
store.queryItemsWithMetadata(filters:) returns an @Observable ItemsWithMetadataQuery that re-emits [ItemWithMetadata] pairs whenever any matching item or metadata row changes. The query reads from the local SQLite store, so no network round-trip per view frame. See SwiftUI reactive queries for the full list.
-
Remote-only mode. Fetch pairs in bulk via
client.items.listWithMetadata(filters:) (wire: GET /items?include=metadata). For ad-hoc single-item lookups use client.metadata.get(itemId:).
Apps built against Marfa before the reactive variant existed often hand-rolled a cache that subscribed to the sync engine’s events stream and re-ran listWithMetadata on each mutation. With ItemsWithMetadataQuery available, that bespoke plumbing is redundant — the SDK owns the observation and invalidation.
Extensions
Namespaced sidecar data. Writes route through the mutation queue in synced mode.
try await client.extensions.set(
itemId: note.id,
namespace: "my-app.reading_progress",
data: ["offset": .int(1234), "percent": .double(45)]
)
let all = try await client.extensions.getAll(itemId: note.id)
let one = try await client.extensions.get(itemId: note.id, namespace: "my-app.reading_progress")
try await client.extensions.delete(itemId: note.id, namespace: "my-app.reading_progress")
Blobs
let response = try await client.blobs.upload(data: imageBytes, mimeType: "image/png")
// response.hash, response.url
let (bytes, contentType) = try await client.blobs.download(hash: response.hash)
let exists = try await client.blobs.exists(hash: response.hash)
let url = client.blobs.url(hash: response.hash)
let signed = try await client.blobs.presignedURL(hash: response.hash, ttl: 300)
upload accepts an optional onProgress: (@Sendable (Int64, Int64) -> Void)? for network-only clients that want inline byte-progress updates. The callback fires on the URLSession delegate queue with (bytesSent, totalBytes) — hop to the main actor yourself if you’re driving SwiftUI state from it. Synced-mode clients ignore the callback; progress for queued uploads arrives via BlobUploadProgressQuery on the reactive layer instead.
let response = try await client.blobs.upload(
data: imageBytes,
mimeType: "image/png",
onProgress: { sent, total in
print("\(sent) / \(total)")
}
)
Blob operations require a live server. A pure-local client throws LocalModeUnsupportedError for every blob method except url(hash:). The same guard applies to client.types, client.keys, and client.webhooks.
Search
let results = try await client.search(
query: "orwell",
filters: SearchFilters(type: "core.bookmark", tier: .library)
)
// Tag filtering — AND semantics, matching ListFilters.tags.
let tagged = try await client.search(
query: "orwell",
filters: SearchFilters(tags: ["fiction", "classic"])
)
SearchFilters.tier is a TierFilter? — pass .library or .feed to narrow, or omit (nil) to return both. There’s no .all synonym; nil already expresses that. SearchFilters.tags requires items to have every listed tag. system.* items are excluded from search by default; opt in with an explicit type: "system.<X>" filter.