Upside Down Research, LLC · Est. MMXXIII A little different look at the world
Pre-release · subject to change
Documentation

HTTP API

Tupshar exposes a small REST API over HTTPS. The base URL for the research preview is:

https://api.tupshar.housecarl.cloud

Every /v1/* request must carry an API key as a Bearer token:

Authorization: Bearer tupk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

See API keys for how to get one. Examples below assume:

export TUPSHAR_HOST="https://api.tupshar.housecarl.cloud"
export TUPSHAR_KEY="tupk_…"

Documents are identified by a server-assigned ULID (e.g. 01JZ8K3M9QF2YV7X4B6N0CWE5T), not by filename. The filename is metadata you can search on; the ULID is the address.


Create a document — POST /v1/file

Send a JSON body. filename and contents are required; tags, properties, and links are optional.

curl -sS -X POST "$TUPSHAR_HOST/v1/file" \
  -H "Authorization: Bearer $TUPSHAR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "notes/planning.md",
    "contents": "Quarterly planning. Owners: Paul, Dana. Review in two weeks.",
    "tags": ["meeting", "planning"],
    "properties": {"project": "tupshar"}
  }'

201 Created returns the full document metadata (and contents):

{
  "id": "01JZ8K3M9QF2YV7X4B6N0CWE5T",
  "filename": "notes/planning.md",
  "tags": ["meeting", "planning"],
  "properties": {"project": "tupshar"},
  "links": [],
  "size_bytes": 60,
  "token_count": 9,
  "unique_terms": 9,
  "sha256": "9f2b…",
  "analyzer_version": "v1",
  "version": 1,
  "created_at": "2026-06-12T21:00:00Z",
  "updated_at": "2026-06-12T21:00:00Z",
  "accessed_at": "2026-06-12T21:00:00Z",
  "etag": "\"sha256:9f2b…|1|1789…\"",
  "contents": "Quarterly planning. Owners: Paul, Dana. Review in two weeks."
}

Pass Idempotency-Key: <uuid> to make a retried create safe. Save the id — it addresses the document for every other call.


Read a document — GET /v1/file/{id}

curl -sS "$TUPSHAR_HOST/v1/file/01JZ8K3M9QF2YV7X4B6N0CWE5T" \
  -H "Authorization: Bearer $TUPSHAR_KEY"

200 OK returns the same shape as create. Options:

  • ?include=metadata — return metadata only, omit contents.
  • If-None-Match: "<etag>" — return 304 Not Modified if the document hasn't changed (the response always carries the current ETag).

HEAD /v1/file/{id} returns the same headers (ETag, Last-Modified, Content-Length) with an empty body — useful for an existence/version check.


Replace a document — PUT /v1/file/{id}

Updates are optimistic-concurrency controlled. You must send the current ETag in If-Match; without it you get 428 Precondition Required, and if it's stale you get 412 Precondition Failed.

curl -sS -X PUT "$TUPSHAR_HOST/v1/file/01JZ8K3M9QF2YV7X4B6N0CWE5T" \
  -H "Authorization: Bearer $TUPSHAR_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "sha256:9f2b…|1|1789…"' \
  -d '{"filename": "notes/planning.md", "contents": "Revised plan. Review next Friday."}'

200 OK returns the new metadata with an incremented version and a new etag.


Delete a document — DELETE /v1/file/{id}

curl -sS -X DELETE "$TUPSHAR_HOST/v1/file/01JZ8K3M9QF2YV7X4B6N0CWE5T?cascade=true" \
  -H "Authorization: Bearer $TUPSHAR_KEY"

204 No Content. Idempotent — deleting an absent document still returns 204. ?cascade=true also removes inbound links from other documents that pointed at this one.


Search — GET /v1/query

One endpoint, four modes selected by which parameter you pass. All are GET with query-string parameters (never a POST body). Default page size is 25, max 100; next_cursor is reserved (currently always null).

Full-text — ?token=

BM25-ranked search over document contents. Terms are normalized by the analyzer and OR-matched (a document matching any term is returned, ranked by summed BM25 score). Up to 32 terms; more returns 422 too_many_terms. There is no phrase, AND, or negation syntax in this preview.

curl -sS -G "$TUPSHAR_HOST/v1/query" \
  -H "Authorization: Bearer $TUPSHAR_KEY" \
  --data-urlencode "token=planning review"
{
  "mode": "token",
  "results": [
    {"id": "01JZ8K…", "filename": "notes/planning.md", "tags": ["meeting","planning"], "updated_at": "2026-06-12T21:00:00Z", "score": 3.42}
  ],
  "limit": 25,
  "total_estimate": 1,
  "next_cursor": null
}

Filename — ?filename= with ?match=

match is exact (default), prefix, or glob. Glob supports */?; the metacharacters []{}|\()^$+ are rejected with 422 unsupported_glob_metachar.

curl -sS -G "$TUPSHAR_HOST/v1/query" -H "Authorization: Bearer $TUPSHAR_KEY" \
  --data-urlencode "filename=notes/" --data-urlencode "match=prefix"

Tag — ?tag=

Case-insensitive exact tag match. Order with ?sort=updated (default) or ?sort=accessed.

curl -sS -G "$TUPSHAR_HOST/v1/query" -H "Authorization: Bearer $TUPSHAR_KEY" \
  --data-urlencode "tag=planning"

Property — ?property=

?property=key matches documents that have the property; ?property=key=value matches an exact value.

curl -sS -G "$TUPSHAR_HOST/v1/query" -H "Authorization: Bearer $TUPSHAR_KEY" \
  --data-urlencode "property=project=tupshar"

Filename, tag, and property results omit score; otherwise the response envelope is the same (mode, results, limit, next_cursor).


File operations — POST /v1/file/{id}/invoke

Body: {"action": "<name>", "params": {…}}. Available actions:

ActionScopeEffect
statsreadLive token statistics for the document
scorereadPer-term BM25 breakdown
retokenizewriteRe-run the analyzer and refresh the full-text index
set_propertieswriteMerge/replace entries in the property cloud
add_linkwriteAdd a typed link to another document
remove_linkwriteRemove a link

summary, links_graph, and diff are reserved and return 501 with code: "reserved_for_future_version".

Links are typed and directional. rel is one of references (default), supersedes, derived_from, related. A document may have up to 256 outbound links.

curl -sS -X POST "$TUPSHAR_HOST/v1/file/01JZ8K…/invoke" \
  -H "Authorization: Bearer $TUPSHAR_KEY" -H "Content-Type: application/json" \
  -d '{"action": "add_link", "params": {"target_id": "01JZ9M…", "rel": "supersedes"}}'

List your documents — GET /httpserver/files

Metadata-only listing of the documents owned by the calling key. Params: page (default 0), limit (default 50, max 500), include.

curl -sS -G "$TUPSHAR_HOST/httpserver/files" -H "Authorization: Bearer $TUPSHAR_KEY" \
  --data-urlencode "page=0" --data-urlencode "limit=50"
{"files": [ /* FileMetadata rows */ ], "pagination": {"page": 0, "limit": 50, "offset": 0}}

Health

  • GET /healthz200 {"status":"ok"} whenever the process is alive (liveness).
  • GET /readyz200 {"status":"ready"} once the database is connected, 503 {"status":"starting"} before then (readiness).

These are unauthenticated. Prometheus metrics live on a separate port — see Monitoring.


Errors

Every error uses one envelope and a documented code. See the full table in Errors:

{"error": {"code": "precondition_required", "message": "If-Match header is required for updates", "request_id": null}}

See also