The built-in assistant: how questions become Web-Algebra plans, how it is set up, and how conversations are stored

The assistant turns a question into a Web-Algebra plan, shows the plan, and runs it when the reader says so. It runs in the browser as part of the client stylesheet and talks to a web-algebra service beside the platform, which is optional and off by default — see Setup. The user guide covers using it. To work with LinkedDataHub from an agent of your own instead, see Connect an agent.

How it works

  1. The client sends the question to /webalgebra/plans on the dataspace's own origin, with the current document, the dataspace's SPARQL endpoint and ontology, and the last three turns of the conversation that ran, each with its plan, its outcome and up to a hundred of the rows it returned.
  2. nginx forwards /webalgebra to the web-algebra service together with the client certificate of the connection. A request without a client certificate is answered with 401, since the service has nobody to act for.
  3. The service asks the model for a plan, checks it against its executor, and answers with the plan, a one-sentence summary and the operations it uses. The client shows them as a card.
  4. On Execute, the client submits the plan to /webalgebra. The service answers 202 with an execution id, and the client polls /webalgebra/{id}/result once a second, showing each step as the executor reports it.
  5. When the plan has run, the client sends the result to /webalgebra/answers for a sentence that sums it up, then stores the turn in the conversation's document and reloads the documents the plan wrote.

A plan that only reads may run as soon as it arrives. One that writes always waits for the reader to press Execute.

Access control

The service runs every plan as the reader who asked. It presents the secretary's WebID certificate to the platform, with an On-Behalf-Of header naming the reader's WebID. The platform honours it because every agent's WebID document that the platform creates, at sign-up or at the first Google or ORCID login, states that the secretary delegates that agent (<secretary> acl:delegates <agent>). Plans are therefore subject to the reader's own authorizations: a plan can read and write what the reader can, and nothing else.

nginx clears any On-Behalf-Of and Client-Cert headers arriving from outside, so identity comes only from the TLS connection. The web-algebra service is reachable only through nginx.

On a deployment that enables the assistant, the Assistant button appears for a signed-in agent with append access to the document, and a conversation accepts new questions under the same condition.

Setup

The web-algebra service runs the ghcr.io/atomgraph/webalgebra-server-ldh image, which is proprietary: AtomGraph publishes the image but not its source, and LinkedDataHub's license does not cover it. The assistant is therefore opt-in.

docker-compose.yml declares the web-algebra service under the assistant Compose profile, so it does not start unless the profile is active, and the Assistant button stays hidden until the platform is told that the service runs. Writing plans from questions also needs an OpenAI API key:

  1. Enable the profile in .env:
    COMPOSE_PROFILES=assistant
    The profile starts the service, and the platform reads the same variable: it then publishes lds:assistant true for every dataspace in its /context, which is what the button is shown on. Set it in .env rather than with docker compose --profile, which the platform does not see.
  2. Put the key in secrets/openai_api_key.txt
  3. Declare the secret and hand it to the service in docker-compose.override.yml:
    secrets:
      openai_api_key:
        file: ./secrets/openai_api_key.txt
    services:
      web-algebra:
        environment:
          - OPENAI_API_KEY_FILE=/run/secrets/openai_api_key
        secrets:
          - secretary_cert_password
          - openai_api_key
  4. Recreate the services: make up

Without a key the service still runs plans, but declines to write them: a question is answered with a message saying so, shown in the card. The sentence that sums up a result also comes from the model; without it, the card shows the rows alone.

The service's variables are listed in the configuration reference.

Running without the service

Without the profile, the rest of the stack runs as usual: nginx looks the service up per request instead of at startup, and answers /webalgebra with 502. Conversations stored while the assistant was enabled are still drawn from the store, but a new question in them fails with an error in its card.

Data model

A conversation is an ldh:Chat resource, placed in a document by an object block like a view or a chart. Its turns are ldh:ChatTurn resources in the same document, and the chat's rdf:_1, rdf:_2, … members in the order they were asked. The first question writes the chat, the object block and the document's next rdf:_N for the block in one request; the chat and its block share a fragment suffix, and the chat's title is the first question, cut near 80 characters. Each turn is written when its answer has landed, or has failed to.

Properties of an assistant turn
Property Value
ldh:question What the reader asked
ldh:answer What the plan did, read by the model, as an rdf:XMLLiteral: an XHTML div of paragraphs, lists, emphasis, code and links to what the plan returned, when the model gave one. Other markup is dropped, and so are links that are not http(s). Turns stored before answers were XHTML hold a plain string
ldh:plan The Web-Algebra plan as an rdf:XMLLiteral: its summary, its operations and the plan itself
ldh:execution What running the plan reported, as an rdf:XMLLiteral: the steps, the documents written, and the first hundred rows or resources of the result
ldh:outcome How the turn went, in one line: rows or resources returned, documents written, no results, the failure's message, or declined when the service answered in words instead of with a plan
dct:created When the turn was stored

A stored conversation is drawn from these resources when the document is rendered again, so it shows the result as it was when the turn ran; nothing is run again. Since the chat is an ordinary resource, its object block can also be placed in another document, and questions asked there are stored in the chat's own document.

@prefix ldh: <https://w3id.org/atomgraph/linkeddatahub#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix dct: <http://purl.org/dc/terms/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<https://localhost:4443/customers/> rdf:_3 <https://localhost:4443/customers/#block-91c0…> .

<https://localhost:4443/customers/#block-91c0…> a ldh:Object ;
    rdf:value <https://localhost:4443/customers/#chat-91c0…> .

<https://localhost:4443/customers/#chat-91c0…> a ldh:Chat ;
    dct:title "Which five customers ordered the most in 1997?" ;
    rdf:_1 <https://localhost:4443/customers/#turn-5d7e…> .

<https://localhost:4443/customers/#turn-5d7e…> a ldh:ChatTurn ;
    ldh:question "Which five customers ordered the most in 1997?" ;
    ldh:answer "<div xmlns="http://www.w3.org/1999/xhtml"><p>The five customers with the most orders in 1997 were:</p><ol><li><a href="…">Save-a-lot Markets</a>: 17 orders</li>…</ol></div>"^^rdf:XMLLiteral ;
    ldh:plan "<wa:plan …>…</wa:plan>"^^rdf:XMLLiteral ;
    ldh:execution "<wa:execution …>…</wa:execution>"^^rdf:XMLLiteral ;
    ldh:outcome "5 rows" ;
    dct:created "2026-10-17T09:12:44Z"^^xsd:dateTime .