1. Introduction

Plaid is a platform, which means that it is not a complete app on its own. Rather, it provides an implementation of the "boring" parts of a linguistic annotation app, including a user system, a database layer, and more. Building an app with Plaid allows you to focus your attention on just the interesting parts of your app including the UI and logic which is specific to your annotation framework.

The purpose of this document is to teach you how to think about and use Plaid. If you’d rather just dive into a real app programmed with Plaid, check out the UD editor example app.

2. Key Concepts

2.1. Projects and Layers

In Plaid a project is a collection of documents which all have the same data model. Plaid’s data model is configurable on a per-project basis, allowing you to have as many or as few annotation types as you would like. For example, perhaps in one project you might only annotate a single part-of-speech tag per word, while in another you might want a POS tag, a lemma, and a gloss for each word. These projects would have different configurations in order to appropriately accommodate the annotation needs of each.

The means by which this configuration is performed is the layer. (If you have used ELAN before, note that this is similar to ELAN’s tiers.) Loosely speaking, a layer corresponds to a single "type" of annotation: for instance, in the example above where we want to collect a POS tag, lemma, and gloss for each word, we would have three span layers associated with the project. We will discuss layers more shortly.

2.2. Users and Permissions

Plaid offers a built-in user system which allows individuals to log in with a password. By default, each project is private, and one of three permissions levels is needed in order to interact with one:

  • Maintainers have full privileges for working with projects: they may edit documents and also modify the project’s configuration.

  • Writers may edit documents belonging to a project, but may not modify the project’s configuration.

  • Readers may only read documents belonging to a project—they may not make any edits to the project’s documents or configuration.

Additionally, a global admin role exists, which allows the user to see and edit all data in the Plaid instance.

2.3. Clients

Plaid’s functionality is exposed for development as a REST API. This allows you to use Plaid from any programming language with an HTTP client library.

Additionally, we provide official clients for two programming languages, in plaid-client-py/ (Python) and plaid-client-js/ (JavaScript). These clients provide an API for interacting with Plaid that is idiomatic for each programming language and frees you from concern about low-level details of HTTP requests. Here is an example of how to use the Python client:

client = PlaidClient("http://localhost:8085", "<SECRET_TOKEN>")

projects = client.projects.list()
print("Available projects:", projects)

first_project_id = projects[0]["id"]
first_project = client.projects.get(first_project_id)
print("First project:", first_project)

documents = client.projects.list_documents(first_project_id)
first_document_id = documents[0]["id"]
first_document = client.documents.get(first_document_id, include_body=True)
print("First document:", first_document)

The same, in JavaScript:

client = PlaidClient("http://localhost:8085", "<SECRET_TOKEN>")

projects = client.projects.list()
console.log("Available projects:", projects)

firstProjectId = projects[0].id
firstProject = client.projects.get(firstProjectId)
console.log("First project:", firstProject)

documents = await client.projects.listDocuments(firstProjectId)
firstDocumentId = documents[0].id
firstDocument = client.documents.get(firstDocumentId, true)
console.log("First document:", firstDocument)

2.3.1. Pagination

Collection endpoints (documents, users, API tokens, audit, and similar lists) are paginated. Over raw HTTP they return an envelope of the form {"entries": […​], "next-cursor": "…​"}, where each page holds at most 100 entries by default (1000 maximum, set via ?limit=). To fetch the next page, pass the opaque next-cursor value back verbatim as ?cursor= (it is null on the final page). The cursors are keyset-based, so pages stay stable under concurrent inserts.

The official Python and JavaScript clients handle this for you: their .list()-style methods (e.g. projects.list(), projects.list_documents(…​) / listDocuments(…​)) transparently follow the cursors and return the full flat list. Per-page variants (e.g. list_documents_page / listDocumentsPage) are available when you want to drive pagination yourself.

2.4. Audit Log

Every write to the database is recorded in an append-only audit log which provides an account of who performed every change. Audit history is retained indefinitely — it is both the forensic record and the source for document time travel (see below), so pruning it trades away history. Operators monitor disk via the /health endpoint’s audit block.

The log can be queried like so:

for entry in c.documents.audit("35424d64-a077-4a29-8006-5a0c3b76aedb"):
    time = entry["time"]
    username = entry["user"]["username"]
    description = "; ".join([o["description"] for o in entry["ops"]])
    print(f"{username}, {time}: {description}")

# Output:
# Luke G, 2025-07-05T09:13:39.614Z: Create document "Document 1" in project 0f0f0574-ae5a-4060-814c-c5bbdce14d67
# Luke G, 2025-07-09T20:27:59.611Z: Update document 35424d64-a077-4a29-8006-5a0c3b76aedb name to "New Document Name"

2.4.1. Custom messages

Each write’s description is generated automatically (e.g. Patch metadata on span …). When a write is part of some larger, more meaningful action, you can supply your own message that replaces the auto-generated one — so the log reads Mark machine-provided annotation as approved instead. Set it for a scope of writes and every write inside that scope is recorded with it (including each operation of a batch):

with c.audit_message("Mark machine-provided annotation as approved"):
    c.spans.set_metadata(span_id, {"approved": True})
await c.withAuditMessage("Mark machine-provided annotation as approved", () =>
  c.spans.setMetadata(spanId, { approved: true }));

The message may template any of the endpoint’s own path, query, or body parameters with {…​} placeholders, filled in by the server. Placeholder names are matched case- and separator-insensitively, so {spanId}, {span-id}, and {span_id} all refer to the same parameter — just write the parameter the way your client spells it (snake_case in Python, camelCase in JavaScript):

with c.audit_message("Approve span {span_id}"):
    c.spans.set_metadata(span_id, {"approved": True})
# logged as: Approve span 35424d64-a077-4a29-8006-5a0c3b76aedb

For a single write you can skip the scope and pass the message as the last argument to any write method instead:

c.spans.set_metadata(span_id, {"approved": True}, audit_message="Approve span {span_id}")
await c.spans.setMetadata(spanId, { approved: true }, "Approve span {spanId}");

A placeholder that names no parameter of the request is left as-is. The message is stored verbatim, so do not template a parameter that may carry a secret (e.g. a password) into the log. Custom messages only affect this human-readable description; the structured forensic record (the operation type and the per-row before/after images) is unchanged.

2.5. Real-time Messaging

Plaid offers a simple system for real-time communication on a per-project basis. This is intended to support two purposes:

  • Ad hoc client-to-client features which you will implement on top of this communication channel, such as chat between individual annotators.

  • Audit log listening, allowing clients to receive immediate notice whenever a change has been made to any document in the project. (Note that these are sent automatically by Plaid.)

Note
Messaging is a general-purpose medium. For the common case of plugging in an external tool—an NLP model, a tokenizer, an importer—that performs work on request, use Services below, which is purpose-built for that and addresses requests to a specific service rather than broadcasting them.

This functionality is exposed in two simple functions in the client. The send_message/sendMessage function allows a client to broadcast a message to all clients in the project:

client.messages.send_message(project_id, {"purpose": "ping", "message": "ping"})

Note that the second positional argument, the body, can be any JSON value.

On the other end, a client may listen like so. Note that there are two arguments for the message. event_type is "message" for data sent via send_message/sendMessage by another client, and "audit-log" for audit log notifications. Consider an example of listener setup:

def on_event(event_type, event_data):
    if event_type == "message":
        sender = event_data["user"]
        time = event_data["time"]
        contents = event_data["data"]
        print(f"User {sender} sent data `{contents}` at {time}")
    elif event_type == "audit-log":
        user = event_data["user"]
        time = event_data["time"]
        op = event_data["ops"][0]
        op_type = op["type"]
        document_id = op["document"]
        description = op["description"]
        print(f"User {user} performed operation `{op_type}` on document {document_id} at {time}: '{description}'")


client.messages.listen(project_id, on_event)

After the send_message invocation we just saw, this on_event function would produce the following output:

User user1@example.com sent data `{'purpose': 'ping', 'message': 'ping'}` at 2025-07-09T20:14:36.168Z

And suppose that another client executed the following code:

client.documents.update(
    "35424d64-a077-4a29-8006-5a0c3b76aedb",
    name="New Document Name")

The listener’s code above would print this:

User user1@example.com performed operation `document:update` on document 35424d64-a077-4a29-8006-5a0c3b76aedb at 2025-07-09T20:27:59.616Z: 'Update document 35424d64-a077-4a29-8006-5a0c3b76aedb name to "New Document Name"'

2.6. Services

A service is an external program that registers itself on a project and performs work on request. The motivating case is NLP: you might have a Python program that runs a parser, a tokenizer, or a large language model, and you want annotators to be able to invoke it from the editor with the click of a button. Because a service is just another client speaking to Plaid’s REST API, it can be written in any language and run anywhere that can reach the server—it authenticates with an ordinary token (typically a named API token; see Clients) and needs writer access to the project.

There are two sides to the system: a program that serves (offers to do work) and a client that requests work. The server sits in the middle: it tracks which services are currently connected, routes each request to the one service it names, and relays that service’s replies back to the requester. Nothing is broadcast—a request reaches only its target service, and a reply reaches only the client that made the request.

2.6.1. Offering a service

A service calls serve with a handler that Plaid invokes once per incoming request. The handler receives the request’s data and a response helper with three methods: progress(percent, message) to report intermediate progress, complete(result) to return a final result, and error(message) to signal failure.

def handle_request(data, response):
    response.progress(10, "Starting…")
    # whatever work the service does
    result = run_my_model(data["document_id"])
    response.complete({"status": "ok", "spans_created": result})

# `service_id` is the stable identifier clients use to address this service;
# `service_name` and `description` are human-readable. `serve` returns
# immediately with a registration handle; the work happens on incoming requests.
registration = client.messages.serve(
    project_id,
    {"service_id": "my-parser", "service_name": "My Parser",
     "description": "Parses a document with my model"},
    handle_request,
)

# The service stays available as long as the program is running and the
# registration is live. Typically you now block until interrupted:
try:
    while registration.is_running():
        time.sleep(1)
except KeyboardInterrupt:
    registration.stop()   # deregister cleanly

The same in JavaScript:

const registration = client.messages.serve(
  projectId,
  { serviceId: "my-parser", serviceName: "My Parser",
    description: "Parses a document with my model" },
  (data, response) => {
    response.progress(10, "Starting…");
    const result = runMyModel(data.documentId);
    response.complete({ status: "ok", spansCreated: result });
  },
);
// later: registration.stop();

2.6.2. Discovering and requesting

A client first asks which services are available, then sends a request to one by its service_id. discover_services/discoverServices returns every service the project has ever seen: the ones connected right now carry online: true, and previously-seen services that are currently down carry online: false plus a last_seen_at stamp. Only an online service can take work—filter on online before offering a "run" action:

services = client.messages.discover_services(project_id)
# [{"service_id": "my-parser", "service_name": "My Parser",
#   "description": "Parses a document with my model", "extras": {},
#   "online": True, "last_seen_at": "2026-06-12T02:43:17Z"}]

A project maintainer can prune the registry with discard_service/discardService, which forgets a previously-seen offline service (it reappears if it ever reconnects); discarding a currently-connected service is rejected with a 409.

request_service/requestService sends work to a named service and waits for its result. The data you pass is delivered verbatim to the service’s handler, and the value the service passes to complete is what you get back. An optional progress callback fires for each progress update the service reports:

result = client.messages.request_service(
    project_id,
    "my-parser",
    {"document_id": document_id},
    timeout=60.0,
    on_progress=lambda p: print(f'{p["percent"]}%: {p["message"]}'),
)
print("Service returned:", result)
const result = await client.messages.requestService(
  projectId,
  "my-parser",
  { documentId },
  60000,                                  // timeout in ms (Python uses seconds)
  (p) => console.log(`${p.percent}%: ${p.message}`),
);
console.log("Service returned:", result);

If the service reports an error, or the timeout elapses, the request fails with that error rather than returning a result.

2.6.3. Describing a service: tasks, summary, and parameters

The fields above (service_name, description) are enough for a human to recognize a service, but an application that plugs services into a fixed slot—"tokenize this document", "parse it", "transcribe the audio"—needs more: which services are eligible for that slot, what arguments each accepts, and what each one does. A service advertises all of this in its extras, an open metadata map that rides along with registration and comes back verbatim from discovery. Filling it in with the standard shape below lets a client offer the user a service picker, an arguments form, and a readable summary—without hard-coding anything about a particular service.

from plaid_client import TASKS, Param, build_extras

extras = build_extras(
    tasks=[TASKS.TOKENIZE],                          # which slots this service fills
    summary="## My Tokenizer\nSplits text into words and sentences…",  # markdown
    parameters=[                                       # user-controllable arguments
        Param.enum("language", "Language",
                   [("english", "English"), ("german", "German")],
                   default="english",
                   description="Model language."),
    ],
)
# Pass it as the service's extras (a BaseService subclass assembles this for you):
client.messages.serve(project_id, service_info, handle_request, extras)

The standard extras keys are:

tasks

A list of the slots this service fills, drawn from a small fixed vocabulary: tokenize, parse, transcribe, link-vocab. A client offering a "tokenize" action lists exactly the services whose tasks include tokenize.

summary

A longer, human-readable description (Markdown), shown on demand—use it for what the one-line description is too short to say.

parameters

An ordered list of the arguments a user may set per request. Each entry is a small descriptor; the client renders a form from it and sends the chosen values as part of the request data.

A parameter descriptor has a key (the field its value is sent under), a label, and a type, plus optional description, default, and required. The types are string (optional placeholder, multiline), number (optional min, max, step), boolean, and enum/multiselect (each with options, a list of {value, label}). The Param.* builders produce these in Python; in JavaScript you write the equivalent objects.

Note

A parameter’s key is a value—the literal field name the argument is sent under—so it travels over the wire unchanged: a client reads the key exactly as the service declared it (model_size), sends the argument under that key, and it arrives back at the service unchanged. Because a snake_case or camelCase key contains no wire separator, this round-trips in either direction, so the rule is simply: declare each key in your own language’s convention and read the argument back under that same key. (The extras field namesschema_version, tasks, parameters, …—are different: those are recased like the rest of the API, so a Python author writes schema_version and a JavaScript client sees schemaVersion. Only the key is exempt, because it is a value rather than a field name.)

Helpers in both clients save you from re-implementing this shape. On the service-authoring side (Python), Param.* build the parameter descriptors and build_extras assembles the whole object (a BaseService subclass does this for you). On the selecting/UI side (JavaScript), filterServicesByTask picks the services for a slot and getServiceSummary/getParamSchema read a service’s summary and parameter schema. The value logic—turning a schema into initial form values and validated request values—exists in both: buildDefaultValues/coerceParamValues in JavaScript, default_values/coerce in Python.

2.6.4. Presence and persistence

A service can take work only while connected. There is no request queue: a request to a service that is absent (or that disconnects mid-request) fails immediately rather than waiting for the service to come back. This keeps the model simple and is a good fit for the typical setup, where a service is a long-running helper process that is either up or down. If you need a request to survive a service being briefly unavailable, you would build that durability yourself on top of Plaid (for instance, by recording pending work in your own layer and retrying).

What does persist is the discovery record: each registration upserts a per-project "seen services" row (name, description, extras, last-seen time), which is why discovery can list offline services. This lets an application show a stable picker—including a default service a maintainer chose—even when the service happens to be down, instead of the option silently vanishing.

Each service_id admits one live connection per project: a second registration while another instance holds the id is rejected with a 409. A service reconnecting after a network blip is not penalized—if the previously-held connection is dead but not yet reaped, the newcomer simply takes it over.

Note
Like real-time messaging, service activity is not recorded in the audit log—only the document changes a service makes through ordinary writes are. The writes a service performs are attributed to whatever token it authenticates with, so giving a service its own named API token makes its edits clearly identifiable in the audit history.

3. Layer Types

Each project contains a configuration of layers which define a schema for all documents in the project. Each layer holds a single kind of annotation, and each project may have any number of each kind of layer. For instance, you might have two span layers: one for POS tags, and another for lemmas.

3.1. An Example

Suppose we’re working on a project where all we are doing is POS-tagging. The configuration of the project’s layers (in a simplified JSON representation) would look something like this:

{
  id: "1cce50df",
  name: "Example Project",
  textLayers: [
    {
      id: "6283144f",
      name: "Text",
      tokenLayers: [
        {
          id: "d1cc124f",
          name: "Words",
          spanLayers: [
            {
              id: "ad0f5f2c",
              name: "POS tags"
            }
          ]
        }
      ]
    }
  ]
}

This layer structure prescribes the structure of individual documents. Consider a document where we have POS tagged the sentence "Fido barks":

{
  id: "01d01a27",
  name: "Document 1",
  project: "1cce50df",
  textLayers: [
    {
      id: "6283144f",
      name: "Text",
      text: { id: "9cfafcc6", document: "01d01a27", body: "Fido barks" },
      tokenLayers: [
        {
          id: "d1cc124f",
          name: "Words",
          tokens: [
            { id: "54383a26", text: "9cfafcc6", begin: 0, end: 4 },
            { id: "a8758db2", text: "9cfafcc6", begin: 5, end: 10 }
          ],
          spanLayers: [
            {
              id: "ad0f5f2c",
              name: "POS tags",
              spans: [
                { id: "4ed828ea", value: "NOUN", tokens: [ "54383a26" ] },
                { id: "b4ef8082", value: "VERB", tokens: [ "a8758db2" ] }
              ]
            }
          ]
        }
      ]
    }
  ]
}

Notice the following:

  • Each layer has a corresponding kind of data in the document: the text layer has a text, the token layer has tokens, and the span layer has spans.

  • The layers are dependent on each other: the text layer is a dependent of the project, the token layer is a dependent of the text layer, and the span layer is a dependent of the token layer. This is a reflection of conceptual dependencies: tokens are defined as atomized substrings of a text, and spans are defined as groupings of one or more tokens.

  • Each individual entity—whether it is a layer or some data within that layer—has a unique ID

  • Entities refer to others with these IDs—for instance, each span’s tokens value has a list of tokens which constitute that span.

We will continue discussing this example in more detail below.

3.2. Projects and Documents

A project is the root of a layer configuration and has a name.

{
  id: "1cce50df",
  name: "Example Project",
  textLayers: [/* ... */]
}

A project has many documents, and each has a name and a unique ID:

{ id: "01d01a27", name: "Document 1", project: "1cce50df" }

3.3. Texts

For each text layer, each document may have at most one text, which consists of a single string. This string holds all the text which is to be analyzed in dependent layers. A text object looks something like this:

{ id: "9cfafcc6", document: "01d01a27", body: "Fido barks" }

3.4. Tokens

For each token layer, each document may have many tokens, which are defined as substrings of a text:

{ id: "54383a26", text: "9cfafcc6", begin: 0, end: 4 }
{ id: "a8758db2", text: "9cfafcc6", begin: 5, end: 10 }

Note the following:

  1. begin and end must form valid substring indices for the given text. They are 0-based offsets measured in Unicode code points (begin inclusive, end exclusive) — not UTF-16 code units or bytes, so a supplementary-plane character (an emoji, or a Supplementary-Plane script such as Gothic, cuneiform, or CJK Extension B) counts as a single position. Code-point offsets are language-neutral: Python’s str indexing and SQLite’s substr/length already count code points, and Plaid’s server and clients all agree on this unit.

  2. By default, zero-length tokens where begin == end are valid.

  3. By default, tokens may overlap.

  4. Plaid sorts tokens by begin when determining their linear order in the document. For tokens with identical begin, Plaid uses the optional precedence value wherever available, such that tokens with lower precedence appear earlier in linear order.

Token layers are unique in that their invariants are configurable. In addition to the default configuration, it is possible to constrain them further: for example, a token layer may require that no tokens be overlapping in extent of begin and end. For full details, see Token Layer Constraints below.

Tokens are intended to serve as the basic units for further linguistic analysis using spans and relations.

3.5. Spans

For each span layer, each document may have many spans, which are groupings of one or more tokens which have a single value:

{ id: "4ed828ea", value: "NOUN", tokens: [ "54383a26" ] }
{ id: "b4ef8082", value: "VERB", tokens: [ "a8758db2" ] }

There are no restrictions on spans, other than that they must hold at least one token, and that they all must belong to the span layer’s parent token layer.

3.6. Relations

For each relation layer, each document may have many relations, which are directed edges between two spans with a label. Both spans must belong to the relation layer’s parent span layer. For example, if we wanted to extend the example above with a syntactic dependency relation between "Fido" and "barks" expressing that "Fido" is the subject, we could have a relation like this:

{ id: "2f6080ff", source: "b4ef8082", target: "4ed828ea", value: "nsubj" }

3.7. Vocabs

The four basic layer types (text, token, span, and relation) are all project-specific and cannot be used in more than one project. The fifth layer type, the vocab layer, can be used across projects. As its name suggests, this layer is intended for recording occurrences of lexical entries.

The vocab layer itself has a name:

{ id: "2b75b0f9", name: "English" }

The vocab layer has vocab items, which represent lexical entries, each with a canonical form:

{ id: "da8d4549", form: "Fido" }
{ id: "b5c6e64c", form: "bark" }

Finally, vocab links are used to indicate occurrences of lexical entries. Recall the tokens from before:

// "Fido"
{ id: "54383a26", text: "9cfafcc6", begin: 0, end: 4 }
// "barks"
{ id: "a8758db2", text: "9cfafcc6", begin: 5, end: 10 }

We can create links between them and the above vocab items with vocab links like so:

{ vocabItem: "da8d4549", tokens: [ "54383a26" ] }
{ vocabItem: "b5c6e64c", tokens: [ "a8758db2" ] }

Notice that multiple tokens may be specified, allowing for multi-word and non-contiguous lexical items.

3.8. Metadata and Config

It is often desirable to enrich an entity with additional information—for instance, you might want to record some information about the annotator’s confidence in whether a certain span value is correct. Additionally, you might want to do the same with a layer in order to e.g. specify what values are acceptable for spans in a given layer. To accommodate this, Plaid allows arbitrary data to be stored in the config attribute for layer types (project, text layer, token layer, span layer, relation layer, vocab layer) and in the metadata attribute for data types (document, text, token, span, relation, vocab item, vocab link).

You might use a config to store legal tags for a given layer:

{ id: "...", name: "POS Tags", config: { tags: ["NOUN", "VERB", /* ... */] } }

As for metadata, the canonical use is recording whether a human or a machine produced an annotation — Plaid standardizes this as the Provenance convention, including where a model’s confidence and prediction details go:

// Human-made POS tag: NO provenance keys (absence = human)
{
  id: "...",
  tokens: [/*...*/],
  value: "NOUN",
  metadata: {}
}
// Machine-made POS tag, not yet human-verified
{
  id: "...",
  tokens: [/*...*/],
  value: "NOUN",
  metadata: {
    prov: "inferred",
    provSource: "service:my-tagger",
    provProb: 0.8412,                       // flat -> queryable/sortable
    provDetail: {                           // open map for the rest
      model: "my-tagger==2.1",
      uposProbs: { NOUN: 0.8412, PROPN: 0.0966, ADJ: 0.0014 }
    }
  }
}
Note
Config and metadata values are schema-less, meaning that keys and values are unconstrained. If you’d like to see schema support for either of these, please feel free to open an issue.

4. Data Integrity

In collaborative annotation projects, it is crucial to take steps to ensure that data never reaches an invalid state. Plaid provides a few different means for maintaining data integrity, so that you may have confidence that your data will never become corrupt.

4.1. Core Data Integrity Constraints

In the previous section, we noted the constraints which Plaid enforces on each data type. Plaid guarantees that the database will never violate these, no matter what, by ensuring that invalid entities are never created, and often by deleting structures which are indirectly rendered invalid by another change. Consider these examples:

  • If a relation’s source span is deleted, then Plaid deletes the relation as well, because a relation must have a span on either end in order to remain valid.

  • If a few characters are deleted in a text, then all token indexes are updated to maintain validity: tokens containing those characters will shrink or get deleted (if they turn into zero-length tokens), and not containing those characters which are anchored to subsequent text will have their indices decremented by the number of deleted tokens.

  • If a span’s only token is deleted, then the span will deleted, along with any dependent relations.

These invariants have been incorporated into Plaid because of their broad desirability in linguistic annotation. However, some invariants will vary by annotation framework. For example, it is quite common to want a span layer’s spans to be in one-to-one correspondence with tokens in the parent token layer.

For token layers, you can ask Plaid to enforce some commonly needed additional invariants for you, server-side: these are described in Token Layer Constraints below. For everything else, Plaid provides three further mechanisms—strict mode, locking, and atomic batches—which help you coordinate writes from the client so that you can enforce your own data integrity constraints without needing to write any code outside of your client.

4.2. Token Layer Constraints

The constraints in the previous section are always on. Token layers additionally support a set of opt-in, server-side constraints which capture some of the most common token-level invariants in linguistic annotation. Because they are enforced by the server on every write, there is no need for extra measures on the client side in order to maintain them. They are configured per token layer at creation time, and are immutable thereafter.

4.2.1. Overlap Modes

Every token layer has an overlap mode, chosen at creation, which governs how its tokens may sit relative to one another:

  • any (the default): no restrictions. Tokens may overlap, leave gaps, and be zero-width. This is the behavior described in the Tokens section.

  • non-overlapping: no two tokens in the same document may overlap (share any character). Gaps between tokens are still allowed—for instance, the whitespace between words.

  • partitioning: the tokens must form a gap-free, non-overlapping, zero-width-free cover of the whole text. Partitioning is allowed only on a root (parentless) token layer; see Token Layer Hierarchy.

Note

A partitioning layer is, at every moment, in exactly one of two valid states for a given document: empty (no tokens at all—the document is simply not yet tokenized at this layer, which is the state every layer starts in), or a complete partition (the gap-free, overlap-free, zero-width-free cover described above). Plaid never lets it reach a partial state in between: every operation either moves it between empty and complete, or is rejected.

In particular, there is no automatic establishment—a newly created partitioning layer is empty until you bulk-create its partition, and bulk-delete (which must remove the whole partition at once) returns it to empty. The editing operations below each preserve the complete-partition state: a split divides one token, a merge joins two adjacent ones, and a boundary shift only moves a shared boundary within the two tokens it separates (a shift that would collapse, invert, or overshoot a token is rejected).

You set the mode when creating the layer:

# A non-overlapping "words" layer
client.token_layers.create(
    text_layer_id, "Words", overlap_mode="non-overlapping")

Because a partitioning layer must always tile its extent, the ordinary per-token create, delete, and extent-update operations are rejected on it—each would necessarily leave a gap or an overlap. Instead, you build and edit the partition with four operations:

  • bulk-create establishes a whole partition at once.

  • split divides one token into two at an offset.

  • merge combines two adjacent tokens into one.

  • shift-boundary moves the boundary between two tokens, growing one and shrinking its neighbor so that the cover is preserved.

These editing operations are available on any and non-overlapping layers too, where they serve as convenient shortcuts. In every case, dependent spans and vocab-links are preserved: a split keeps them on the original (left) token, and a merge reparents the right token’s spans and vocab-links onto the surviving left token.

# Establish the partition "dogs run" -> "dogs", "run"
result = client.tokens.bulk_create([
    {"token_layer_id": words, "text": text_id, "begin": 0, "end": 4},
    {"token_layer_id": words, "text": text_id, "begin": 5, "end": 8},
])
dogs_id, run_id = result["ids"]

# Split "dogs" into "dog" + "s"
new_id = client.tokens.split(dogs_id, 3)["id"]

# Merge the two halves back together
client.tokens.merge(dogs_id, new_id)

4.2.2. Token Layer Hierarchy

Although all token layers must depend on a text layer, a token layer may also declare another token layer as its parent token layer. As with the parent text layer, this must be done at time of layer creation and it is immutable. This models nesting between levels of tokenization, which is commonly needed for most approaches to interlinear glossed text, where morphemes are nested within whole words which are nested within sentences.

Nesting is expressed purely through code-point offsets: a child token belongs to the parent token whose extent contains it. There is no separate parent pointer on each token to keep in sync. These rules apply to nested token layers:

  • The parent layer must belong to the same text layer as the child, so they index into the same text.

  • The parent layer must be non-overlapping or partitioning—its tokens must not overlap, so that each child has exactly one containing parent. (An any parent is rejected.)

  • A nested layer may not itself be partitioning.

  • Every token in the nested layer must be contained within some parent token. A token that escapes its parent, or that spans across two parents, is rejected.

  • Tokens on a nested layer may not be zero-width—a zero-width token sitting on a parent boundary would belong to two parents at once. (For morphemes that have no clean surface segmentation, see Non-concatenative morphology below.)

Hierarchical containment and overlap mode provide complementary guarantees. Hierarchical containment is used to restrict the ranges in which tokens for that layer may occur: that is to say, only within the extant tokens in the parent layer. On the other hand, the overlap mode dictates how a layer’s own tokens may occur within that range relative to each other.

Here is the canonical three-level IGT hierarchy for the text "dogs run", assuming tl is a text layer and text_id is its text:

# Three token layers: sentences contain words contain morphemes. Only the root
# (sentence) layer may be partitioning. Words are non-overlapping; morphemes are
# `any` so that fused forms (overlapping morphemes) are representable too -- see
# "Non-concatenative morphology" below.
sentences = client.token_layers.create(
    tl, "Sentences", overlap_mode="partitioning")["id"]
words = client.token_layers.create(
    tl, "Words", overlap_mode="non-overlapping",
    parent_token_layer_id=sentences)["id"]
morphemes = client.token_layers.create(
    tl, "Morphemes", overlap_mode="any",
    parent_token_layer_id=words)["id"]

# One sentence covering the whole text
client.tokens.bulk_create([
    {"token_layer_id": sentences, "text": text_id, "begin": 0, "end": 8}
])

# Two words, with a gap at index 4 for the space
client.tokens.bulk_create([
    {"token_layer_id": words, "text": text_id, "begin": 0, "end": 4},
    {"token_layer_id": words, "text": text_id, "begin": 5, "end": 8},
])

# Morphemes within each word: "dog|s", then "run"
client.tokens.bulk_create([
    {"token_layer_id": morphemes, "text": text_id, "begin": 0, "end": 3},
    {"token_layer_id": morphemes, "text": text_id, "begin": 3, "end": 4},
    {"token_layer_id": morphemes, "text": text_id, "begin": 5, "end": 8},
])
Non-concatenative morphology

The morphemes in the example could be seen as purely concatenative: each is a contiguous slice of its word, and together they cover it without gaps. Plenty of morphology does not decompose so cleanly. The French contraction aux, for instance, is analyzed as the preposition à plus the article les, yet neither morpheme corresponds to a contiguous slice of the surface string "aux".

Because the morpheme layer above is any, it accommodates this directly—this is exactly why a morpheme layer is usually any rather than non-overlapping. Represent a fused form with overlapping morphemes that share the fused span, ordered with precedence:

# "aux" is the word token [0,3]; the morpheme layer's overlap-mode is `any`
client.tokens.bulk_create([
    # à
    {"token_layer_id": morphemes, "text": text_id,
     "begin": 0, "end": 3, "precedence": 0},
    # les
    {"token_layer_id": morphemes, "text": text_id,
     "begin": 0, "end": 3, "precedence": 1},
])

Both morphemes span the whole surface form, so the overlap between them is itself the signal that the correspondence is non-concatenative: an agglutinative word yields disjoint, contiguous morphemes, while a fused word yields morphemes that share a span. precedence records their linear order (à before les).

Zero-width morphemes are not an alternative here: tokens on a nested layer may not be zero-width, for the disambiguation reason given above. Anchor the fused morphemes to the whole word’s span, as shown.

4.2.3. Cascading Edits

Plaid’s general philosophy is to make destructive, cascading changes where necessary to preserve its invariants—recall that deleting a token deletes the spans which depended on it. Editing a parent token follows the same philosophy: structural operations cascade down the hierarchy so that nesting always remains valid.

  • Deleting a parent token deletes every token nested within it (and, transitively, their spans, relations, and vocab-links). Deleting a word deletes its morphemes, while leaving the word’s siblings and their morphemes untouched.

  • Splitting a parent at an offset also splits any descendant that straddles that offset, so a nested token is never bisected. (A split that lands exactly on an existing child boundary needs no child split; the children simply re-home into the two new parents by offset.)

  • Shifting a boundary (or otherwise resizing a parent) cascades according to the layer’s mode. On a partitioning parent, the neighbor grows to absorb the freed region, and any descendant straddling the moved boundary is split so that its outer half re-homes to the neighbor. On a non-overlapping or any parent, descendants that retain no overlap with the new extent are deleted—including every child when the parent is shrunk to zero width—and descendants straddling the new edge are trimmed to fit.

  • Merging two parent tokens yields a token spanning both, which therefore still contains all of their descendants—nothing is orphaned.

  • Editing the text body cascades here too: as tokens shrink, grow, or disappear along with the characters they cover (per the core constraints above), nested tokens are clipped consistently, so a child never ends up outside its parent.

Because all of these are enforced and cascaded server-side, you can rely on the hierarchy remaining internally consistent no matter how the document is edited, including under concurrent edits by multiple users.

4.3. Strict Mode

Multiple users may edit the same document simultaneously, and in some cases, undesirable conflicts may occur as users fail to take into account each other’s work. Suppose, for example, that one user is editing a sentence’s lemmas, and the other is editing a sentence’s POS tags. If the lemma editor doesn’t know that a certain POS tag has changed, they might make the wrong decision about which lemma to assign. Plaid clients' optional strict mode causes edits to fail when someone other than the current user has made an edit. Consider this exact scenario in code:

client1.spans.update(lemmaSpanOneId, "lemmaOne")
client2.spans.update(posTagSpanTwoId, "posTagTwo")
// Works fine
client1.spans.update(lemmaSpanTwoId, "lemmaTwo")

When client 1 executes the second lemma span’s value, unless they happened to have loaded the document anew after client 2’s change, they will not be aware of the new POS tag for the second word.

Strict mode causes requests to fail when someone other than the user in strict mode has edited a document since strict mode began. If client 1 had initiated strict mode at the beginning, then the second request would have failed:

client1.enterStrictMode(documentId)
client1.spans.update(lemmaSpanOneId, "lemmaOne")
client2.spans.update(posTagSpanTwoId, "posTagTwo")
// Fails with HTTP 409, since client 2 made a change
client1.spans.update(lemmaSpanTwoId, "lemmaTwo")
// Exit strict mode when desired
client1.exitStrictMode()

This failure gives client 1 the opportunity to reload the document only when it is necessary, allowing them to reconsider the current state of the document before making changes.

4.4. Locking

Sometimes a more heavyweight solution is needed. A lock gives a user exclusive permission to write to a document, preventing all other users from changing its contents. Locks have a 60 second expiration timer by default, and they may be released early or renewed by either explicit renewal or any write to the locked document. Consider:

client2.checkLock(documentId);
// => HTTP 204
client1.acquireLock(documentId);
// -> { userId: "client1", expiresAt: 1752260966446 }
client2.checkLock(documentId);
// -> { userId: "client1", expiresAt: 1752260966446 }
client1.releaseLock(documentId);
// -> HTTP 204
client2.checkLock(documentId);
// -> HTTP 204

Locks are useful for situations where a concurrent edit by another user could yield an invalid state with respect to data integrity constraints beyond what is enforced in Plaid’s core. They should be used only where necessary in order to minimize the risk of invariant violations stemming from concurrent modifications.

4.5. Atomic Batches

Finally, you may also submit multiple requests in batches. Batches are atomic meaning that we guarantee that either they will all succeed or all fail. This is a very useful guarantee whenever you have sophisticated data integrity requirements that must be orchestrated using more than one request.

Here is an example of how to submit a batch using the JavaScript client:

client.beginBatch();
client.documents.update(documentIdOne, "New Name for Doc 1");
client.documents.update(documentIdTwo, "New Name for Doc 2");
try {
    const result = await client.submitBatch();
    console.log("Batch success!")
    for (const response of result) {
        console.log(response);
    }
} catch (e) {
    console.error(`Batch failed: ${e}`)
}

Or in Python:

client.begin_batch()
client.documents.update(document_id_one, "New Name for Doc 1")
client.documents.update(document_id_one, "New Name for Doc 2")
try:
    result = client.submit_batch()
    print("Batch success!")
    for response in result:
        print(response)
except Exception as e:
    print(f"Batch failed: {e}")

Owing to some implementation details, no other writes may be executed while a batch is being processed, so only use them where necessary.

4.6. Trust

For all constraints which you might attempt to enforce using the three mechanisms described above, there is, of course, nothing stopping a malicious user from circumventing them and submitting changes which invalidate your formalism-specific data integrity constraints. For example, if you have a document where you want all spans to be in one-to-one correspondence with tokens, a malicious user could simply circumvent your UI code entirely and craft a malicious request to e.g. create multiple spans associated with a single token. Since there is no server-side validation for this constraint, the server will happily execute it and advance into a database state that does not violate any core data integrity constraints but does violate your formalism-specific constraints.

This is a fundamental limitation of Plaid which was deliberately adopted in order to support frontend-only development. You therefore must only grant write privileges only to users who you trust not to circumvent client-side validation guardrails. Fortunately, we think this is not an onerous imposition in most real-world circumstances.

Note
The token layer constraints described in Token Layer Constraints are the exception to this caveat. Because they are enforced on the server, they cannot be circumvented by a malicious client, and they hold no matter how a request is crafted.

5. Layer Interoperability

Plaid’s goal is to provide infrastructure that will allow you to point multiple apps at the same project, with data being shared across apps as much as possible. For example, someone might gloss their texts in an interlinear (IGT) editor and, on the very same documents, build a dependency treebank in a Universal Dependencies (UD) editor. Done well, this is close to magical: no import/export is needed to work on the same data across two different apps. Done poorly, the two apps will quietly corrupt each other’s data. In this chapter, we consider this very scenario as an example, and describe a discipline to be followed by app developers to ensure that apps play as well as they can with each other.

5.1. Structural Sharing

For most linguistic annotation applications built on top of Plaid, the layers can be divided into two categories. First are substrate layers: these are the primary text layer and some token layers, including the token layer for sentences, words, and maybe morphemes. There are many different apps that would be interested in working with these.

Then there are annotation layers: these hold information that probably only exactly one app cares about, such as a Universal Dependencies part-of-speech tag. These annotations typically do not need to be shared between apps.

Now, recall from Metadata and Config that each app writes its configuration under its own namespace. This configuration can help two different apps, such as one used for UD and another used for IGT, to track only the annotations each cares about: the UD app would use configuration cues to know where to look for its tag and dependency layers, and similarly, the IGT app can use configuration to find the gloss layers it uses, which can happily sit side by side on the same tokens. For annotations, each app is oblivious to the other, because each app only ever looks at the layers it created.

The substrate is the part that the apps do share, but even here they will only agree up to a point. A sentence is a sentence in both apps, and the same is usually true of a word. Below the word, though, the apps may have different needs, and one and the same word may be divided in two different ways. In the UD app, the layer beneath the word holds CoNLL-U syntactic words, which sometimes disagree with the CoNLL-U tokens above them: the English token teacher’s, for example, is a multiword token holding the two syntactic words teacher and 's. In the IGT app, the layer that is closest in purpose to UD’s syntactic word layer holds morphemes, which split on all morphology, so that this same word, teacher’s, is instead divided into the three morphemes teach, -er, and 's. It is therefore not always possible to share all token layers across apps.

A reasonable rule of thumb, then, is to share the substrate as far down as the apps agree, and to let them branch below that point. In our example, the two apps would share the text, the sentence layer, and the word layer, and then, beneath the word layer, the UD app would add its syntactic-word layer while the IGT app adds its morpheme layer. (Recall from Token Layer Hierarchy that a single token layer may have more than one child layer, so both of these can sit side by side under the shared word layer.) The apps agree all the way down to the word, and only then go their separate ways. If two apps genuinely do mean the same thing by a layer, so that their morpheme segmentation really is identical, then they are free to share it as well; but this should always be a deliberate decision rather than a silent assumption.

5.2. Roles

Suppose that a third app comes along that has no knowledge of the UD or IGT apps, and that it wants to find out what the extant layers mean. While there is no perfect solution to this, Plaid provides a small shared vocabulary of roles recorded in each layer’s configuration under the plaid namespace. A layer carries at most one role, and the inventory is deliberately small:

Role Layer What the layer holds

baseline

text layer

The primary text being annotated, over which all the other layers are built.

sentence

token layer

Sentence segmentation, normally a partitioning layer at the root.

word

token layer

The orthographic word: a whitespace-and-punctuation tokenization (CoNLL-U’s token).

syntactic-word

token layer

Grammatical words beneath the orthographic word (CoNLL-U’s word, e.g. the pieces of a multiword token like teacher’s).

morpheme

token layer

Morpheme segmentation beneath the word.

time-alignment

token layer

Tokens aligned to a media timeline, for audio or video.

Plaid itself attaches no meaning to these values. These names are rather blessed conventional names for communicating the meaning of a layer that apps are expected to honor when present. The app that first sets the project up might record the roles of its sentence and word layers like this:

await client.tokenLayers.setConfig(sentenceLayerId, "plaid", "role", "sentence");
await client.tokenLayers.setConfig(wordLayerId, "plaid", "role", "word");

The plaid namespace is reserved for conventions that every app agrees on, which makes it the right home for these shared role tags. An app’s own private configuration, such as its vocabularies, its colors, and the meanings of its fields, belongs instead under that app’s own namespace, where no other app will mistake it for shared information. Keeping these two kinds of configuration apart is what allows several apps to coexist on one project.

We also advise app developers to consider the absence of a role tag to be a meaningful signal: if a layer is not tagged with a role, then as a matter of convention, no other app should read or modify it.

5.3. Provenance

Annotation projects routinely mix human labor with machine output—a parser fills in a first pass, an auto-linker proposes vocabulary links, an importer carries another tool’s analysis—and it matters enormously which is which: machine output is cheap and replaceable, while human judgment is expensive and must never be silently destroyed. Plaid standardizes this distinction as the provenance convention: flat metadata keys on annotation entities (spans, relations, vocab links, and optionally tokens) that put every entity in one of three states.

State Metadata Meaning

human

no provenance keys

A person made it. The absence of the prov key is the discriminator.

machine

prov: "inferred", provSource: "<producer>"

An algorithm or service made it, and no human has vouched for it yet.

verified

the above, plus provConfirmed: true

Machine-made, and a human confirmed or edited it. provSource stays, so machine origin remains traceable.

provSource names the producer: service:<serviceId> for services, rule:<name> for built-in rule algorithms, or an app-specific id such as gloss:doc-frequency or flex-import. The presence of the prov key—not its particular value—is what marks an entity as machine-made; inferred is simply the only value defined today. The keys are deliberately flat scalars, which the query system’s metadata filters match well, so questions like "show me every unverified machine annotation in this document" are ordinary queries.

A producer may also record prediction extras—how confident it was, and what else it considered—in two further reserved slots, split along the queryability line:

provProb

One flat number in [0, 1]: the producer’s probability for the value it chose. Because it is a flat scalar, it filters and orders in the query language, so "review the least-confident machine output first" is an ordinary query. Provide it only when you can honestly produce a probability—a raw log-probability or unnormalized score is not one, and belongs in provDetail instead.

provDetail

One open map for everything else: top-k alternatives or distributions, the model name and version, raw scores. It is deliberately nested (and therefore not query-matchable—anything worth filtering on goes in provProb), and deliberately unconstrained inside, with one piece of advice: record the top handful of alternatives, not whole-vocabulary distributions.

Prediction extras describe the machine’s original prediction. They are kept even after a human edits the entity—the history is valuable for audit and retraining—which means a consumer must not present provProb as confidence in the current value once the entity is verified: any human edit verifies (rule 3 below), so provConfirmed is exactly the flag to check before displaying it.

Of the bundled services, the Whisper transcriber records its per-segment scores this way (provDetail.avgLogprob / provDetail.noSpeechProb / provDetail.model—and no provProb, since a log-probability is not a calibrated probability), and the Stanza parser records provDetail.model and provDetail.language (its pipeline exposes no per-prediction probabilities; a parser that has them would add provProb plus a top-k distribution in the detail map). On the read side, the UD editor consumes distributions a parser records under provDetail.uposProbs / provDetail.xposProbs / provDetail.deprelProbs (each a flat {label: probability} map): the top-k floats above the rest of the vocabulary in the corresponding cell’s dropdown as a "Parser suggestions" group, and machine-made cells describe their origin (producer, model, provProb) in their hover tooltip.

The convention is made operational by a write contract that every machine writer (service, built-in algorithm, importer) is expected to follow:

  1. A machine writer may freely create new material and freely replace machine-made, unverified material—its own or another machine’s. Unverified machine output is by definition replaceable.

  2. A machine writer must never modify or delete human-made or verified material unless explicitly told to, via a per-run, user-facing opt-in. For services, the idiom is a declared boolean overwrite parameter, so the option renders in the standard argument form and is off by default.

  3. Any human edit of a machine annotation verifies it: alongside the new value, the editing app also merges provConfirmed: true. Explicit confirm gestures (clicking to accept a proposed link, for example) do the same.

Rule 3 is what lets editors render unverified machine output distinctly (Plaid’s apps use violet italics) and have the marking dissolve naturally as a human works through the material—each touched annotation graduates to verified, while everything still violet remains fair game for the next machine pass.

For service authors, both clients ship helpers: stampInferred(source) / stamp_inferred(source) builds the fragment to merge into everything you create (every bulk create accepts per-item metadata) and takes the prediction extras as options (stampInferred(source, {prob, detail}) / stamp_inferred(source, prob=, detail=)), serviceSource(serviceId) / service_source(service_id) builds the canonical producer id, and isProtected(metadata) / is_protected(metadata) implements the rule-2 check against material you are about to destroy. Born-verified material—an import carrying upstream human approval, or a guess written only upon explicit user confirmation—uses confirmedInferred / confirmed_inferred instead.

Note

Tokens MAY carry provenance (the bundled parser and tokenizer services stamp the tokens they create), but interactive substrate-filling—a person clicking "tokenize" to run a deterministic segmenter over untouched text—need not bother: it creates nothing destructive and nothing annotation-bearing. The write contract protects annotation content; substrate segmentation is guarded by the structural rules in Token Layer Constraints.

One migration wrinkle: machine output written before a producer adopted this convention carries no provenance keys and therefore reads as human-made. The first guarded re-run over such a document will refuse; one run with overwrite enabled replaces and re-stamps it.

5.4. Reconciliation Across Apps

In Data Integrity, we discussed various ways of maintaining app-specific data model invariants within a certain app. Here, we consider the more difficult matter of one app updating a shared substrate layer in a way that invalidates a different app’s data model invariants.

Suppose that a sentence has been annotated for UD, and that later on, someone opens the same sentence in the IGT app and splits the sentence. From the IGT app’s perspective, this is a totally unproblematic change to make. However, on the UD side, it is a strict requirement that all dependency relations must never cross sentence boundaries, and this change in the IGT app at the shared sentence tokenization layer has caused an invariant violation in the UD app.

There is no way for Plaid itself to know about this invariant, because it is application-specific. Our only option, then, as long as this layer is shared, is to somehow have the UD app deal with this violation the next time that document is opened.

The discipline that resolves this is easy to state, and in the interest of smooth interoperability, we encourage all app developers to adhere to it:

Every time an app opens a document, it should validate all its invariants and automatically repair any violations that may have been introduced by other apps.

In our running example, the next time the document is opened in the UD app, the UD app would validate the data and realize that a relation is straddling a sentence boundary. The simplest thing to do at this point would be to delete the offending relation. Although information is lost this way, we are able to restore data integrity without requiring any human intervention. Ideally, the app would also loudly tell the user what happened, and perhaps even encourage or require review. And indeed, in general, some invariant violations in other situations might require human intervention in order to avoid worse consequences.

Note

It is worth running this same reconciliation not only when a document is first opened, but also whenever a document that is already on screen changes underneath you, for instance in response to a real-time update delivered through Real-time Messaging. Opening the document is the baseline; reconciling on a live update is the very same idea, applied at the moment the substrate comes back into view.

6. Querying

Plaid offers a rich query system for powering searches across one or even multiple projects. Here, we consider a few examples to give you a feel for it. For full details, see the query language reference. The examples below use the JavaScript client and the small "Fido barks" project from An Example.

6.1. A First Query

A simple query specifies what to find, and lists the conditions a match must satisfy in where:

const posLayerId = /* ... some UUID ...*/;
// every span on the pos layer whose value is NOUN
const { results } = await client.query({
  find: ["?s"],
  where: [["span", "?s", { layer: posLayerId, value: "NOUN" }]],
});
// results: one row per match, each carrying the ids you asked to find
//   here, the single span over "Fido"

Read that where line as a sentence: "a span, call it ?s, on the pos layer, whose value is NOUN." The ?s is a variable — any name you invent, written with a ? — and every match comes back as a row holding one id per name in find.

6.2. Relationship Clauses

Here is a more interesting query, where we search for a noun immediately followed by a verb:

await client.query({
  find: ["?noun", "?verb"],
  where: [
    ["span", "?noun", { layer: posLayerId, value: "NOUN" }],
    ["span", "?verb", { layer: posLayerId, value: "VERB" }],
    ["covers", "?noun", "?t1"],   // the noun span covers a token ?t1
    ["covers", "?verb", "?t2"],   // the verb span covers a token ?t2
    ["precedes", "?t1", "?t2"],   // and ?t1 falls immediately before ?t2
  ],
});
// in "Fido barks": one match — the NOUN over "Fido" and the VERB over "barks"

When multiple clauses are provided to where, all must be satisfied in order to yield a match. In the latter three clauses, we use relationship clauses, which are used to enforce structural relations between entities. covers requires that a span have a token as one of its constituent tokens, and precedes requires that one token come immediately before another in linear order. These are two of several relationship clauses; the reference catalogs the rest.

6.3. Return Types

By default a match is a tuple of ids. Different return types are available, identified by return:

// a single number — the total match count
await client.query({ find: ["?s"], where: [["span", "?s", { layer: posLayerId }]],
                     return: "count" });
// -> { return: "count", count: 2, truncated: false }   // two pos spans

// or full entities, exactly as a GET would return them
await client.query({ find: ["?s"], where: [["span", "?s", { layer: posLayerId, value: "NOUN" }]],
                     return: "entities" });
// -> results[0][0] is { id, layer, document, value: "NOUN", tokens: [...] }

6.4. Visibility

Normal permissions restrictions apply to queries: a query submitted by a user is only executed over data that user has permission to read. In order to further restrict visibility to e.g. a single project, the scope parameter may be provided in a query:

await client.query({
  find: ["?s"],
  where: [["span", "?s", { layer: posLayerId, value: "NOUN" }]],
  scope: { projectIds: ["<a project's id>"] },
});

Queries always read the current state of your data: there is no time-travel parameter (one passed as asOf is refused). For a document’s history, see the Audit Log.

6.4.1. Document Time Travel

A document GET accepts an as-of query parameter carrying an ISO-8601 instant, e.g. GET /api/v1/documents/{id}?as-of=2026-06-01T12:00:00Z&include-body=true, and returns the document exactly as it stood at that moment — reconstructed from the audit log, so it is always available, requires no configuration, and is never stale. A few details worth knowing:

  • Time travel is read-only and only defined on the top-level document GET; document sub-routes (media, locks, metadata) and all other endpoints refuse as-of with a 400.

  • A timestamp landing in the middle of an atomic batch is treated as falling just before the batch began — you can never observe a half-applied batch.

  • A timestamp at or after the present simply returns the current state.

  • Documents that have since been deleted remain readable at timestamps when they existed, for users with current access to the project.

  • Access control always reflects current project membership, not membership as of the requested time.

6.5. Further Reading

See the query language reference for comprehensive information about how queries work.

7. Configuration

Plaid is configured through a single TOML file. You do not have to create it: on first launch Plaid writes config.toml into the data directory (data/config.toml) with every setting at its default value. To change something, edit that file and restart the server.

To load a config file from a non-default location, either pass --config on the command line or set the PLAID_CONFIG environment variable:

$ java -jar plaid.jar --config /etc/plaid/config.toml
# or
$ PLAID_CONFIG=/etc/plaid/config.toml java -jar plaid.jar

8. Development

For information on how to work on Plaid itself (not an app which uses Plaid), see the development guide.