Agents

Knowledge base API

Search a base, request a written answer and report its outcome from an Odoo module or any other software.

11 min readUpdated November 13, 2026

The API gives third-party software what the Knowledge bases page does: search for passages, request a written answer, then say what became of it. It does not depend on your Odoo connection: any software able to make an HTTPS call can use it.

Authentication

Create a key in the API keys tab of a base. It starts with aid_kb_ and is shown only once.

  • By default, a key is limited to the base where it was created and can only search.
  • Tick Allow writing answers to give it the right to request answers.
  • Tick Give access to every base of the workspace only if the software needs it.

Send the key in the Authorization header of every call:

curl https://api.aidoo.ai/v1/knowledge/bases \
  -H "Authorization: Bearer aid_kb_your_key"

A key stays on the server side

Never put an aid_kb_ key in a web page or a mobile application. In Odoo, store it in a system parameter readable by admins only.

List the bases

GET /v1/knowledge/bases returns the bases the key can read, with their labels.

{
  "bases": [
    {
      "id": "6734c0f2a1b2c3d4e5f60718",
      "name": "Support",
      "description": "",
      "use_case": "support",
      "labels": [
        { "key": "product", "name": "Product", "values": ["Accounting", "Payroll"], "required": false }
      ],
      "entry_count": 412
    }
  ]
}

POST /v1/knowledge/search

FieldTypeRole
basetext, requiredidentifier of the base
querytext, requiredthe question (2,000 characters at most)
labelsobjectfilter, for example {"product": ["Payroll"]}
limitintegernumber of passages (20 at most), the base's own by default
curl https://api.aidoo.ai/v1/knowledge/search \
  -H "Authorization: Bearer aid_kb_your_key" \
  -H "Content-Type: application/json" \
  -d '{"base": "6734c0f2a1b2c3d4e5f60718", "query": "error ERR-1045 at closing"}'
{
  "results": [
    {
      "entry_id": "6734c1aa...",
      "title": "Year-end closing",
      "url": "https://aide.exemple.com/cloture",
      "section": "Year-end closing > Common errors",
      "excerpt": "The ERR-1045 message appears when a journal still contains draft entries.",
      "score": 0.71,
      "exact_match": true,
      "labels": { "product": ["Accounting"] },
      "stale": false
    }
  ]
}

score is the closeness of meaning (0 to 1). exact_match is true when the passage contains the exact words of the question. stale flags an entry whose revalidation date has passed. An empty list means the base has nothing close enough.

Searching is free.

Request an answer

POST /v1/knowledge/answers (key with the right to write answers)

FieldTypeRole
basetext, requiredidentifier of the base
bodytext, requiredthe customer's request (12,000 characters at most)
subjecttextthe subject of the ticket or message
labelsobjectsearch filter
external_reftextyour reference, for example ticket-482:message-9051
clarifybooleanfalse to forbid clarifying questions on this call (true by default)
curl https://api.aidoo.ai/v1/knowledge/answers \
  -H "Authorization: Bearer aid_kb_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "base": "6734c0f2a1b2c3d4e5f60718",
    "subject": "Closing impossible",
    "body": "Hello, the closing fails with the ERR-1045 message. What should I do?",
    "external_ref": "ticket-482:message-9051"
  }'
{
  "answer": {
    "id": "6734d2bb...",
    "base": "6734c0f2a1b2c3d4e5f60718",
    "action": "propose",
    "reason": "review_required",
    "body": "The ERR-1045 message means that a journal still contains draft entries. Post these entries, then run the closing again.",
    "body_html": "<p>The ERR-1045 message means that a journal still contains draft entries. Post these entries, then run the closing again.</p>",
    "confidence": 1,
    "checks": { "citations": true, "quotes": true, "links": true, "numbers": true, "grounded": 1 },
    "missing_info": [],
    "questions": [],
    "sources": [
      { "index": 1, "entry_id": "6734c1aa...", "title": "Year-end closing", "url": "https://aide.exemple.com/cloture", "section": "Common errors", "stale": false }
    ],
    "credits": 3,
    "outcome": null,
    "created_at": "2026-11-13T10:12:44.000Z"
  }
}

The action field

This is the one your software must follow.

actionWhat your software does
proposeshows body to a person, who reviews, corrects and sends it
sendmay send body without review
clarifybody asks the customer the questions listed in questions: the request is too vague to answer
acknowledgebody is an acknowledgement message: the base does not contain the answer
nonenothing to send; sources may list the closest entries

send is only returned if automatic sending is turned on in the settings of the base and all the checks pass. As long as you have not turned it on, you will never receive send.

The reason field says why: review_required, auto_send, checks_failed, missing_info, stale_source, below_threshold, no_passage, abstained, not_grounded, unclear_request.

body is in simple Markdown (paragraphs, lists, bold); body_html is its HTML version, already escaped. The links to the articles are not in the text: they are in sources, which you display however you like.

Clarifying questions

When the request does not say clearly enough what is expected ("it does not work", with no software, screen or error message), the base does not guess: action is clarify, questions holds one to three specific questions and body presents them in a message ready to review, in the customer's language. No source is returned and no fact is stated.

A clear request the base cannot answer never yields clarify: it follows the setting "When the base does not contain the answer".

When the customer replies, call the API again with:

  • in body, the original request followed by their reply;
  • a new external_ref (the one of the new message);
  • "clarify": false, so that the base answers or hands over instead of asking more questions.

A written answer can also end with a question, when one detail would change the procedure: action is then propose and questions holds that question. Questions can be turned off for the whole base in its settings ("When the request is not clear enough").

One reference, one answer

With external_ref, a second call for the same reference returns the same answer, without recomputing or charging anything again. Give a reference that changes with every new message from the customer (ticket and message identifiers), not just the ticket identifier.

Duration and credits

The call is synchronous and takes from a few seconds to a minute. Call it from a background job, never while a record is being saved. It uses workspace credits (credits field), except when nothing is found. When the credits are used up, the response is 402.

Read an answer again

GET /v1/knowledge/answers/{id} returns the same shape as above.

Report the outcome

POST /v1/knowledge/answers/{id}/outcome

Tell Aidoo what became of the answer. This is what feeds the indicators of the base, and what will make it possible to set the automatic sending threshold based on facts.

FieldTypeRole
outcometext, requiredsent_unchanged, sent_edited, rejected, auto_sent or reopened
final_bodytextthe text actually sent
reasontextreason for a rejection (200 characters at most)
curl https://api.aidoo.ai/v1/knowledge/answers/6734d2bb.../outcome \
  -H "Authorization: Bearer aid_kb_your_key" \
  -H "Content-Type: application/json" \
  -d '{"outcome": "sent_edited", "final_body": "Hello, post your draft entries then run the closing again."}'

If you send final_body, Aidoo compares the two texts and corrects between sent_unchanged and sent_edited by itself. The text sent is used for this measurement and then discarded: it is neither kept nor added to the base.

reopened is reported when the customer reopens the ticket after an answer: it is the best signal that an answer was wrong.

Reading the indicators

GET /v1/knowledge/stats?base={id}&days=30 (key allowed to write answers)

Returns the indicators of the Indicators tab over 7, 30 or 90 days: number of requests, decisions taken (by_action), outcomes reported (by_outcome), share sent unchanged (unchanged_rate), share with no answer in the base (no_answer_rate), credits used, and the most cited entries.

curl "https://api.aidoo.ai/v1/knowledge/stats?base=6734c0f2a1b2c3d4e5f60718&days=30" \
  -H "Authorization: Bearer aid_kb_your_key"

unchanged_rate only covers the answers whose outcome you reported: without a call to outcome, it stays at zero.

Errors and limits

CodeMeaning
400missing or invalid field (KNOWLEDGE_INVALID_INPUT, KNOWLEDGE_INVALID_LABELS)
401key missing, unknown or revoked
402workspace credits used up (KNOWLEDGE_CREDITS_EXHAUSTED)
403the key does not have this right, or not on this base (KNOWLEDGE_SCOPE_REQUIRED)
404base or answer not found
409an answer is already in progress for this external_ref: try again a little later (KNOWLEDGE_ANSWER_IN_PROGRESS)
429too many calls: wait for the delay given in the Retry-After header
503service temporarily unavailable, try again

Limits per key: 60 calls per minute, including 20 answer requests.

Connect an Odoo module

Here is the shortest path to proposing an answer on a ticket, whether with the Helpdesk of Odoo Enterprise, the OCA helpdesk_mgmt module or any model that carries a discussion thread.

1. Store the settings

Two system parameters (ir.config_parameter), readable by admins only:

  • aidoo_knowledge.api_key: the aid_kb_ key;
  • aidoo_knowledge.base_id: the identifier of the base.

2. Add the fields to the ticket

from odoo import fields, models


class HelpdeskTicket(models.Model):
    _inherit = "helpdesk.ticket"

    aidoo_answer_id = fields.Char(copy=False)
    aidoo_answer_action = fields.Selection(
        [("propose", "To review"), ("send", "Sent automatically"),
         ("clarify", "Questions for the customer"), ("acknowledge", "Acknowledgement"),
         ("none", "Nothing to send")],
        copy=False,
    )
    aidoo_answer_body = fields.Html(copy=False, sanitize=True)
    aidoo_answer_sources = fields.Json(copy=False)
    aidoo_answer_pending = fields.Boolean(default=True, copy=False, index=True)

A newly created ticket has aidoo_answer_pending set to true: the scheduled action is what will process it. To also propose an answer on every new message from the customer, set this field back to true in message_post when the author is the customer.

3. Call the API from a scheduled action

import requests

from odoo import api, models

API = "https://api.aidoo.ai/v1/knowledge"


class HelpdeskTicket(models.Model):
    _inherit = "helpdesk.ticket"

    @api.model
    def _cron_aidoo_answers(self, limit=20):
        params = self.env["ir.config_parameter"].sudo()
        key = params.get_param("aidoo_knowledge.api_key")
        base = params.get_param("aidoo_knowledge.base_id")
        if not key or not base:
            return
        for ticket in self.search([("aidoo_answer_pending", "=", True)], limit=limit):
            last = ticket.message_ids.filtered(lambda m: m.message_type == "email")[:1]
            payload = {
                "base": base,
                "subject": ticket.name or "",
                "body": ticket.description or "",
                "external_ref": f"ticket-{ticket.id}:message-{last.id or 0}",
            }
            try:
                response = requests.post(
                    f"{API}/answers",
                    json=payload,
                    headers={"Authorization": f"Bearer {key}"},
                    timeout=90,
                )
            except requests.RequestException:
                continue  # the next run will retry
            if response.status_code == 402:
                break  # credits used up: no point in insisting
            if response.status_code in (409, 429, 503):
                continue  # already in progress, or too many calls: the next run will retry
            if response.status_code != 201:
                ticket.aidoo_answer_pending = False
                continue
            answer = response.json()["answer"]
            ticket.write({
                "aidoo_answer_pending": False,
                "aidoo_answer_id": answer["id"],
                "aidoo_answer_action": answer["action"],
                "aidoo_answer_body": answer["body_html"],
                "aidoo_answer_sources": answer["sources"],
            })
            self.env.cr.commit()

Schedule _cron_aidoo_answers every minute or every five minutes. The call takes several seconds: it must never be made from create or write.

Filter by label

If your base has a product label, derive its value from the team or the type of the ticket and pass "labels": {"product": ["Payroll"]}. The search then only looks at the entries of that product, which clearly improves the answers of a multi-product support team.

4. Show the answer on the record

Display aidoo_answer_body in a panel of the ticket form, with the list of sources and two buttons:

  • Send: posts the text (corrected if needed) in the discussion thread with message_post, then reports the outcome;
  • Ignore: reports the rejected outcome.
    def action_aidoo_send(self):
        self.ensure_one()
        self.message_post(
            body=self.aidoo_answer_body,
            message_type="comment",
            subtype_xmlid="mail.mt_comment",
            partner_ids=self.partner_id.ids,
        )
        self._aidoo_outcome("sent_unchanged", final_body=self.aidoo_answer_body)

    def _aidoo_outcome(self, outcome, final_body=None, reason=None):
        params = self.env["ir.config_parameter"].sudo()
        key = params.get_param("aidoo_knowledge.api_key")
        if not key or not self.aidoo_answer_id:
            return
        payload = {"outcome": outcome}
        if final_body:
            payload["final_body"] = final_body
        if reason:
            payload["reason"] = reason
        try:
            requests.post(
                f"{API}/answers/{self.aidoo_answer_id}/outcome",
                json=payload,
                headers={"Authorization": f"Bearer {key}"},
                timeout=20,
            )
        except requests.RequestException:
            pass  # the outcome is an indicator, it must not block sending

Always send sent_unchanged with final_body: Aidoo requalifies it as sent_edited if the text was edited.

5. Automatic sending, later

When action is send, your module can post the answer without waiting, then report the auto_sent outcome. Only turn it on in the settings of the base after observing, for several weeks, the share of answers sent without edits.

The acknowledgement message

When action is acknowledge, body brings no answer: it is an acknowledgement of receipt. Decide in your module whether it goes out on its own or waits for a person, and never present it as an answer to the problem.