for agents/docs/vault/llms.txtv0.7.65 · 12 Oct 2026

Home / Docs / Creating and writing a vault from JavaScript

Surface: a page on a *.sgit.ai site, outside every vault host. The other surfaces →

Creating, opening and writing a vault from JavaScript

A web page that wants its own vault (newsroom.sgit.ai keeping a reader's notes, a tool saving its output) needs the same five actions the CLI has: create, open, read, write and commit, push. All five already exist in the browser, in the library that runs dev.vault.sgraph.ai. This page is the recommendation to use that library and nothing else, the exact methods for each action, and a technical account of what goes over the wire. Reading one file with a read key is the read-only half of the same story.

The one-sentence version. Load the twelve vault library scripts from dev.vault.sgraph.ai, construct an SGSend with the API endpoint and an access token, then SGVault.create or SGVault.open; every file write is one encrypted commit sent as one POST /batch, and pushIfMatch() publishes it. One code base: every site that writes vaults uses this library, so a fix in it is a fix everywhere.

Checked on 12 October 2026. The library is vault UI v0.2.3 (sgraph_ai_app_send__ui__vault/v0/v0.2/v0.2.3/_common/js/lib/ in SGraph-AI__App__Send); the twelve files served by dev.vault.sgraph.ai are byte-identical to that folder. Every code sample below was run: create, write and push with the library, sgit clone and sgit push with sgit-ai 0.21.0, then open and read back with the library.

The recommendation: one code base

CodeWhere it livesUse it for
The vault UI library: SGSend, SGVault and its partsSGraph-AI__App__Send, served at dev.vault.sgraph.ai/_common/js/lib/Everything that writes: create, open with a vault key, read, write, commit, push, pull, merge, history. It is what dev.vault.sgraph.ai itself runs, so it is the most exercised writer there is
The tools modules: core/vault-client, vault-session, vault-write, the sg-vault-* componentsSGraph-AI__Tools, served at dev.tools.sgraph.ai/core/ as versioned ES modulesRead-only embedding (sg-vault-content, sg-vault-key, sg-vault-fetch). Their write path is an older port of the library above: one PUT per object, no compare-and-swap, no branch index, and vault creation from token-style keys only
The readers on this site: assets/vault-embed.js, assets/vault-docs.jsThis repoReading one file with a published read key, as documented here
The sgit CLISGit-AI__CLI, on PyPI as sgit-aiThe reference for the format: agents, scripts, and the 0.21 features the browser does not have yet (below)

So a site does not write its own vault writer, and does not copy one from the tools modules. Two ports drift: the tools port needed its own fix before it could open vaults made by sgit 0.16, and the format is moving again with 0.19 to 0.21. Gaps found by using the library are fixed in the library, in SGraph-AI__App__Send, and every site picks them up.

Loading the library

The files are plain scripts, not ES modules: each declares a global class, and the sg-vault--*.js files add methods to SGVault.prototype. Order matters. A classic <script src> from another origin needs no CORS, so this works on any page:

<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-send/sg-send-crypto.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-send/sg-send.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault-crypto.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault-object-store.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault-ref-manager.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault-commit.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault--file-ops.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault--folder-ops.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault--sync.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault--history.js"></script>
<script src="https://dev.vault.sgraph.ai/_common/js/lib/sg-vault/sg-vault--branches.js"></script>

Three things to know about that list:

The five actions

1 · Create a vault

const sgSend = new SGSend({ endpoint: 'https://dev.send.sgraph.ai', token: accessToken })
const passphrase = randomPassphrase()                 // 24 chars of [a-z0-9], as sgit init makes
const vault = await SGVault.create(sgSend, passphrase, { name: 'My notes' })
const vaultKey = vault.getVaultKey()                  // "<passphrase>:<vault_id>": store it, it is the only way back in

function randomPassphrase(n = 24) {
  const abc = 'abcdefghijklmnopqrstuvwxyz0123456789', out = []
  while (out.length < n) {                             // rejection sampling: no modulo bias
    for (const b of crypto.getRandomValues(new Uint8Array(n))) if (b < 252 && out.length < n) out.push(abc[b % 36])
  }
  return out.join('')
}

create picks a random 8-character vault id, derives the keys, and sends the first commit, the named ref, the clone ref and the branch index as one POST /batch. There is no separate "create vault" endpoint: the server creates the vault on the first write and remembers the SHA-256 of the write key that made it, and from then on only that write key can write. The write needs an access token, so creating a vault needs an access token; opening and reading do not.

The passphrase is the caller's to choose, and its strength is the vault's strength: there is no rate limit on guessing it offline. Use 24 random characters as above, the same shape sgit init generates. The vault's display name is stored encrypted, in .vault-settings.json.

2 · Open a vault

const vault = await SGVault.open(sgSend, vaultKey, { cloneBranch: deviceBranch })   // see the box below
vault.writable        // true: a vault key carries the write key
vault.name            // decrypted from .vault-settings.json
vault.listFolder('/') // [{ name, type: 'file'|'folder', size, content_hash }]

open reads the branch index, then the named ref live (refs are never cached), then this browser's clone ref, and loads the root folder. Sub-folders load lazily. If the CLI or another browser published while this one sat still, open shows the newer published head rather than the stale clone. A key copied from sgit output carries a prefix (sgit_private_vault_ followed by the key); open does not strip it, so pass the key through SGVaultCrypto.stripKeyPrefix() first. For a read key instead of a vault key, see read-only access.

Give every device its own clone branch. The clone branch defaults to web-ui, so two browsers opening the same vault key commit to the same working ref, and when their work diverges open keeps showing that shared clone head rather than what was published. Pass { cloneBranch: id } with an id made once per device and kept in local storage, for example 'nr-' + crypto.randomUUID().slice(0, 8). A client that only reads should use its own name too, or openReadOnly.

3 · Read a file

async function ensureLoaded(vault, folder) {          // load every folder on the path
  let path = ''
  for (const part of folder.split('/').filter(Boolean)) {
    path += '/' + part
    if (vault.needsLoading(path)) await vault.loadSubTreeOnDemand(path)
  }
}
await ensureLoaded(vault, '/notes')
const bytes = await vault.getFile('/notes', 'today.md')  // ArrayBuffer, decrypted
const text  = new TextDecoder().decode(bytes)

Files are addressed as (folder, name), folder paths start with /. Files over 4 MB are fetched through a presigned S3 URL automatically.

4 · Write: every write is a commit

async function writeFile(vault, folder, name, data) {
  await ensureLoaded(vault, folder)                       // REQUIRED, see the warning below
  let path = ''
  for (const part of folder.split('/').filter(Boolean)) { // create missing folders
    path += '/' + part
    if (!vault.listFolder(path)) await vault.createFolder(path)
  }
  const bytes  = typeof data === 'string' ? new TextEncoder().encode(data) : data
  const exists = (vault.listFolder(folder) || []).some(e => e.name === name && e.type === 'file')
  return exists ? vault.updateFile(folder, name, bytes) : vault.addFile(folder, name, bytes)
}
await writeFile(vault, '/notes', 'today.md', '# Today\n')

There is no staging area, and no separate commit call. addFile, updateFile, removeFile, renameFile, moveFile, createFolder and the other folder operations each make one commit, with a generated message such as Add today.md, and send the blob, the changed trees, the commit and the clone ref as one POST /batch. addFiles([{ folderPath, fileName, fileData }]) writes many files as one commit. A commit lands on this browser's private clone branch; nobody else sees it until it is pushed.

Load a folder before writing into it. After open, sub-folders are placeholders. addFile('/notes', …) on a folder that was never loaded makes a commit called Add … whose tree still points at the old folder, so the file is silently missing and the push reports success. Reproduced on 12 October 2026 with v0.2.3. The ensureLoaded call in writeFile above prevents it; the fix belongs in the library (below).

5 · Push: publish with compare-and-swap

async function publish(vault) {
  if (vault._refManager.lastRawRef(vault._refFileId) === undefined)
    await vault.getBehindCount()               // only if never read: arms the compare-and-swap
  try { await vault.pushIfMatch(); return }
  catch (e) { if (e.code !== 'ECAS') throw e } // somebody else published first
  const theirs = await vault._refManager.readRef(vault._refFileId)
  const r = await vault.merge(theirs, { publish: false })   // fast-forward or three-way, file level
  if (r.conflicts.length) throw new Error('conflicts: ' + r.conflicts.join(', '))
  await vault.pushIfMatch()
}

pushIfMatch() moves the named ref only if the server still holds the exact bytes this client last read for it. If someone else published in between, it throws ECAS and the caller merges and retries. That is the pattern the vault host's own headless kernel uses (kernel-app-handlers.js, _safePush), and the two _refManager calls are copied from it, which is why they reach into an underscore field; a public safePush() is on the upstream list.

Read-only access

A read key, 64 hex characters plus the vault id, decrypts but cannot write:

const cred = SGVaultCrypto.parseReadOnlyCredential(readKey)   // "<64 hex>:<vault_id>", prefix optional
const c    = await SGVaultCrypto.deriveReadOnlyCreds(cred.vaultId, cred.readKeyHex)
const ro   = await SGVault.openReadOnly(new SGSend({ endpoint }), c.vaultId, c.readKeyB64, c.refFileId)
ro.writable   // false

For a page that only shows one file, the 90-line reader in Reading one file out of a vault is smaller.

The methods

CallWhat it does
new SGSend({ endpoint, token })The HTTP client. endpoint is https://dev.send.sgraph.ai (or https://send.sgraph.ai); token is the access token, sent as x-sgraph-access-token, needed only for writes
SGVault.create(sgSend, passphrase, { name, description })New vault with a random id; one batch write. Returns an open, writable vault
SGVault.open(sgSend, vaultKey, { cloneBranch })Open with passphrase:vault_id. cloneBranch names this client's private branch; the default, web-ui, is shared by every browser, so set it
SGVault.openReadOnly(sgSend, vaultId, readKeyB64, refFileId)Open with a read key; writable is false
vault.getVaultKey() · vaultId · name · writable · setName(n) · getStats()Key and properties. setName is a commit
listFolder(path) · needsLoading(path) · loadSubTreeOnDemand(path)Browse; sub-folders load on demand
getFile(folder, name)Decrypted bytes
addFile · addFiles · updateFile · removeFile · renameFile · moveFileOne commit each (addFiles: one for the lot)
createFolder · removeFolder · renameFolder · moveFolderOne commit each
pushIfMatch() · pull() · merge(commitId, { publish }) · getAheadCount() · getBehindCount()Sync. push() also exists; do not use it
logCommits({ limit, from }) · listTreeAt(commitId, path) · readFileAt(commitId, path)History: [{ id, parents, tree_id, timestamp_ms, message }], and any file at any commit
getBranches() · switchBranch(headRefId)Branches from the branch index
SGVaultCrypto.stripKeyPrefix · classifyKey · parseVaultKey · deriveKeysKey handling, no network

Errors carry a code where the caller is expected to act: ECAS (the named ref moved: merge and retry), ENOMATCH (compare-and-swap before any read), EREADONLY (a static host, no write API). HTTP failures throw with the status and the server's detail, for example a 403 when the access token is missing.

How it works

Keys: everything comes from the vault key

DerivedHowUsed for
read key, 32 bytesPBKDF2-HMAC-SHA256, 600,000 iterations, password = passphrase, salt sg-vault-v1:<vault_id>AES-256-GCM over every object, ref and name; the HMAC key for file ids. This is the read key sgit prints, in hex
write key, 32 bytesThe same PBKDF2 with salt sg-vault-v1:write:<vault_id>Never encrypts anything. Sent as lowercase hex in x-sgraph-vault-write-key; the server compares its SHA-256 with the one the first writer set
named ref idref-pid-muw- + first 12 hex of HMAC-SHA256(read key, "sg-vault-v1:file-id:ref:<vault_id>")Where the published head lives
branch index ididx-pid-muw- + HMAC of sg-vault-v1:file-id:branch-index:<vault_id>The list of branches
clone ref idref-pid-snw- + HMAC of sg-vault-v1:file-id:branch-ref:<vault_id>:<branch>This client's working head (web-ui by default)

The server receives none of this except the write key's hex, and stores only its hash. Addresses are computed, never looked up: a holder of the read key knows where the head is before making a request.

Objects: four kinds, all ciphertext

Two refs: the clone branch and the named branch

Each client commits to its own clone ref; push copies that commit id into the named ref, which is what everyone else reads. This is the two-branch model the CLI uses, so a browser and a terminal are two clones of the same vault. The branch index (branch_index_v1) lists the branches so that sgit clone can find the named ref.

The wire

CallAuthUsed by
GET /api/vault/read/<vault_id>/<path>nonerefs (with cache: 'no-store'), the branch index, objects
POST /api/vault/batch/<vault_id> with {"operations": [{op, file_id, data, match}]}token + write key when any op writesevery commit, every push, bulk reads. op is read, write or write-if-match; data and match are base64; chunked at 50 operations. A compare-and-swap miss comes back as "status": "conflict" inside an HTTP 200
PUT /api/vault/write/<vault_id>/<path>token + write keysingle writes outside a batch
GET /api/vault/presigned/read-url/<vault_id>/<path>nonefiles over 4 MB

So one file write is one request carrying four or more encrypted objects, and one push is one request carrying the ref and the index. The full endpoint list is in API: vault objects, the headers in API: authentication.

With sgit 0.21: what holds and what does not

What holds, tested 12 October 2026. A vault made with SGVault.create was cloned by sgit-ai 0.21.0 with every file intact; a CLI commit and push was then opened and read back by the library; a library updateFile and pushIfMatch was pulled by the CLI, which then pushed again without complaint. For a vault at the default format, which is every vault until its owner raises it, the browser and the CLI interoperate in both directions.

What does not hold yet, from the code and History integrity:

sgit 0.19–0.21 featureThe library todayConsequence
Signed commits; --feature signatures-requiredWrites signature: nullOn a vault that requires signatures, the CLI refuses to pull or clone past a browser commit. Do not write from the browser to such a vault
Format 2: 32-hex object idsAlways 12-hexReadable by everyone, but a format-2 vault receives 48-bit ids from the browser
The branch index as a shared document, written with compare-and-swappushIfMatch and push overwrite it with a single entryDrops the other clones' entries and the format gate from the server's copy. The CLI merges its local copy back on its next pull, so nothing is lost while some clone still holds it; an entry no surviving clone holds is gone
Deterministic tree encryptionRandom IVs for tree entriesHarmless: trees still decrypt; an unchanged folder rewritten by the browser gets a new id instead of the old one

None of that matters to a site keeping its own vaults (newsroom.sgit.ai's reader vaults are written only by newsroom.sgit.ai) as long as nobody raises their format. It matters as soon as a vault is shared with the CLI under the 0.21 policies, and the fix is in the library, not around it.

What the library needs, in SGraph-AI__App__Send

In priority order, each a change to _common/js/lib/sg-vault/ that every site then inherits:

  1. Fix the lazy-folder write. A write into an unloaded folder should load it (or refuse), never commit a tree that drops the file.
  2. A public safePush(): read the ref if it was never read, pushIfMatch, merge and retry once on ECAS. Then retire unconditional push().
  3. A clone branch per device by default, made once and remembered, instead of the shared web-ui.
  4. create(…, { vaultId }): let the caller supply the vault id. A vault whose id and passphrase are derived (the passkey's computed top vault) cannot be created through the public API today, only opened.
  5. Publish at a versioned path (/_common/js/lib/v0.2.3/…, or one bundled file) so a site can pin a version without copying, and move the inline SGVault.createFromToken out of index.html. Token-style keys were dropped from the CLI in 0.15 as guessable, so new sites should not use that path at all.
  6. Merge the branch index with compare-and-swap instead of overwriting it, keeping other entries, the gate, the keys and the tags.
  7. Sign commits (P-256, JCS signing bytes, author_key_id; the CLI's tests/_fixtures/interop_vectors.json is the test vector), then format 2 ids when the gate says so.

For newsroom.sgit.ai

Reading one file out of a vault · API: vault objects · The two-branch model · History integrity · Credentials