♥ Support

ob-prompt.el — Org Babel for LLM Prompts

Table of Contents

1. Prerequisites

This document assumes basic familiarity with:

  • Emacs (editing files, evaluating code)
  • Org mode (headings, src blocks, C-c C-c execution)

You do not need to be an Org or Babel expert to use ob-prompt, but if this is your first exposure to Emacs or Org mode, the raw source of this file may feel dense.

If you’re new, start here:

Once you understand what an Org src block is and how C-c C-c works, everything in this document should read naturally.

2. Introduction

ob-prompt turns Org Babel into a lightweight, reproducible interface for large language models. A prompt is just a src block:

#+begin_src prompt :model claude-sonnet-4-6
Explain how LLMs aid in creating literate programs.
#+end_src

Press C-c C-c and the response appears as a #+RESULTS: block. Variables, system prompts, and the :preamble mechanism (which feeds the buffer content above the block as context) all compose naturally with the rest of Org.

Two execution backends are supported:

  • HTTP: Sends prompts to any OpenAI-compatible API endpoint.
  • CLI: Pipes prompts through a shell command (:command), so any CLI tool that reads stdin and writes stdout becomes an LLM backend, e.g. claude code.

This documentation actually contains the prompt blocks that were used to write it, along with the LLM responses they produced at the time. In other words, the text you are reading is itself the result of executing those prompts.

The document therefore doubles as both:

  • A manual for ob-prompt
  • A worked, reproducible example of using Org Babel to collaborate with an LLM while writing documentation

The following is how you will encounter my prompts and their respones:

Hey LLM, cast a spell on the reader to give them good luck and have their code never throw!

By the parentheses of Lisp and the quiet power of plain text, I bestow upon you this small but sincere enchantment.

May your buffers stay uncorrupted, your diffs be minimal and intentional, and your abstractions reveal themselves at just the right moment.

May warnings speak early, errors arrive rarely, and when a bug appears, may it confess immediately under a single well-placed M-x eval-buffer.

Go forth with steady hands and reproducible builds. Your code shall not throw — and if it ever does, it will do so kindly, with a clear stack trace.

So it is written.

3. Quick Start

Get the code from GitHub and add the following to your Emacs configuration:

(add-to-list 'load-path "/path/to/ob-prompt")

(org-babel-do-load-languages
 'org-babel-load-languages
 '((emacs-lisp . t)
   (prompt . t)))

Then write a prompt block and press C-c C-c:

#+begin_src prompt :model claude-sonnet-4-6
Explain monads in simple terms.
#+end_src

The implementation is split into small helpers. Each section introduces a concept, shows the code, then demonstrates it with a live example you can C-c C-c to see the actual output. The full automated test suite lives separately in ob-prompt-test.el.

4. Header Arguments

4.1. Custom Header Arguments

These are the prompt-specific header arguments. Babel merges them with the standard set (:results, :exports, :var, :session, etc.). Declaring them here enables completion in the block header.

Any :keyword value pair flows through to params regardless of whether it is declared - but listing them explicitly is good hygiene.

(defconst org-babel-header-args:prompt
  '((model    . :any)   ;; LLM model identifier
    (system   . :any)   ;; system prompt string
    (endpoint . :any)   ;; API endpoint URL
    (api-key  . :any)   ;; API key (string or evaluated form)
    (preamble . :any)   ;; include buffer content above block as context
    (command  . :any)   ;; shell command for CLI backend (reads stdin)
    (debug    . :any))  ;; return raw request/response instead of content
  "Prompt-specific header arguments for ob-prompt.")

4.2. Default Header Arguments

When no header arguments are provided, the following defaults apply. They are chosen to integrate smoothly with an in-document prompting workflow and minimize boilerplate while remaining explicit and reproducible.

(defvar org-babel-default-header-args:prompt
  '((:results . "raw")
    (:exports . "both") ;; Ensures both prompt and result are exported
    (:eval . "no-export") ;; Do not re-evaluate prompt blocks on export
    (:wrap . "assistant") ;; Provide concrete boundries to responses
    (:noweb . "yes")) ;; Enable <<block-name>> inclusion by default
  "Default header arguments for prompt src blocks.")

5. Variable Expansion

Babel's default variable expansion prepends name=value lines before the block body — a convention designed for programming languages where such assignments are syntactically valid. In a prose prompt, those extra lines produce nonsensical output. Our expand function instead performs inline $name substitution directly in the body text, so variables read naturally within the surrounding prose.

(defun org-babel-expand-body:prompt (body params)
  "Expand BODY for a prompt block by substituting :var references.
Each :var binding replaces $name in BODY with the variable's value."
  (let ((expanded body))
    (dolist (pair (org-babel--get-vars params) expanded)
      (setq expanded
            (replace-regexp-in-string
             (regexp-quote (format "$%s" (car pair)))
             (save-match-data (format "%s" (cdr pair)))
             expanded
             t t)))))
#+name:double
#+begin_src python :python3
  def double(x):
      return 3 * x
#+end_src

#+begin_src prompt
What is wrong with my code?

<<double>>
#+end_src

#+RESULTS:
#+begin_assistant
The function’s behavior does not match its name.

=double= implies multiplying by 2, but the implementation multiplies by 3.  
Either rename the function (e.g. =triple=) or change the body to:

return 2 * x

As written, the code is syntactically correct but semantically misleading.
#+end_assistant

6. Message Assembly

The messages array is the core data structure sent to any OpenAI-compatible API. This helper assembles it from the prompt body and optional system/preamble context, returning a vector of alists ready for json-encode.

(defun ob-prompt--build-messages (body &optional system preamble-text)
  "Build the messages vector for the API request.
BODY is the user's prompt text.
SYSTEM, when non-nil, becomes a system message.
PREAMBLE-TEXT, when non-nil, is wrapped in <context> tags
and prepended to the user message."
  (let ((user-content (if preamble-text
                          (format "<context>\n%s\n</context>\n\n%s"
                                  preamble-text body)
                        body))
        (messages '()))
    (when system
      (push `((role . "system") (content . ,system)) messages))
    (push `((role . "user") (content . ,user-content)) messages)
    (vconcat (nreverse messages))))
Explain the role of vconcat

vconcat converts a Lisp list into a vector.

In this context, the OpenAI-compatible API expects messages to be an array, not a list. In Emacs Lisp, JSON arrays are encoded from vectors, while lists are ambiguous and often encode as objects instead.

The function builds messages incrementally as a list (easy to push onto), then uses vconcat to produce a vector with the same elements, ensuring json-encode emits a proper JSON array in the request payload.

6.1. Example

(ob-prompt--build-messages
 "What patterns do you see?"
 "You are a code reviewer."
 "* Header\nSome earlier buffer content...")
[((role . "system") (content . "You are a code reviewer."))
 ((role . "user")
  (content
   . "<context>\n* Header\nSome earlier buffer content...\n</context>\n\nWhat patterns do you see?"))]

7. HTTP backend

ob-prompt ships with a built-in HTTP backend that speaks the OpenAI-compatible chat completions protocol. It is the default transport — used whenever :command is absent.

7.1. Building the Request Payload

The payload is the top-level JSON object sent to the API. Keeping this as a separate helper means we can test the exact structure without touching the network.

(defun ob-prompt--build-payload (model messages)
  "Build the JSON-ready alist for the API request.
MODEL is the model identifier string.
MESSAGES is the vector returned by `ob-prompt--build-messages'."
  `(("model" . ,model)
    ("max_completion_tokens" . 16384)
    ("messages" . ,messages)))

7.1.1. Example

The full payload, ready to be passed to json-encode:

(ob-prompt--build-payload
 "gpt-4"
 (ob-prompt--build-messages "Explain monads."))
(("model" . "gpt-4") ("max_completion_tokens" . 16384)
 ("messages" . [((role . "user") (content . "Explain monads."))]))

7.2. Parsing the Response

The API returns a JSON object with a choices array. This helper extracts the assistant's text content, signaling a clear error if the response is malformed or contains an API error.

(defun ob-prompt--parse-response (json-string)
  "Extract the assistant's message content from JSON-STRING.
Signals `user-error' if the response indicates an API error
or has an unexpected structure."
  (let* ((data (condition-case err
                   (json-read-from-string json-string)
                 (json-error
                  (user-error "ob-prompt: failed to parse response JSON: %s"
                              (error-message-string err)))))
         (err-msg (alist-get 'message (alist-get 'error data))))
    (when err-msg
      (user-error "ob-prompt: API error: %s" err-msg))
    (condition-case nil
        (alist-get 'content
                   (alist-get 'message
                              (aref (alist-get 'choices data) 0)))
      (error
       (user-error "ob-prompt: unexpected response structure: %s"
                   (truncate-string-to-width json-string 200))))))
Can the response come as position greater than 0 in the choices?

In practice, almost never — and for your use case, you can safely assume index 0.

More detail:

  • The OpenAI-compatible APIs define `choices` as an array to support:
    • `n > 1` completions (legacy / sampling use cases)
    • parallel or speculative decoding (internal)
  • When `n` is omitted (default = 1), the server returns exactly one element.
  • That element is always at index 0.
  • I'm not aware of any production API that returns the "primary" response anywhere else.

So:

  • Yes, additional elements can exist in theory.
  • No, you should not expect the actual response to appear at a non-zero index unless you explicitly requested multiple completions.

Your current parser is therefore correct and idiomatic.

If you wanted to be maximally defensive, the only reasonable extension would be:

  • assert `(> (length choices) 0)`
  • optionally concatenate contents when `n > 1`

But for ob-prompt's design goals (single prompt → single response), indexing `[0]` is exactly right.

7.2.1. Example

Parsing a realistic API response — you can see exactly what gets extracted:

(ob-prompt--parse-response
 "{\"id\":\"chatcmpl-abc\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"A monad is a design pattern.\"}}]}")
A monad is a design pattern.

7.3. ASCII-safe JSON Encoding

url-http-create-request rejects any request whose string-bytes exceeds its length — i.e. any non-ASCII byte in the assembled request string. Since json-encode can produce literal non-ASCII characters (em-dashes, curly quotes, etc. from preamble context), we escape them to \uNNNN JSON sequences first. Every conforming JSON parser decodes these transparently.

(defun ob-prompt--json-encode-ascii (object)
  "Encode OBJECT as JSON with all non-ASCII characters escaped to \\uNNNN.
This avoids `url-http-create-request' failing with
\"Multibyte text in HTTP request\" (Emacs Bug#23750)."
  (let ((json (json-encode object)))
    (replace-regexp-in-string
     "[^\x00-\x7f]"
     (lambda (char)
       (format "\\u%04x" (string-to-char char)))
     json nil t)))

7.4. HTTP Transport

(defun ob-prompt--request (endpoint api-key payload)
  "Send PAYLOAD to ENDPOINT using API-KEY via built-in `url'.
Returns the response body as a string.
Signals `user-error' on HTTP or transport errors."
  (let* ((url-request-method "POST")
         (url-request-extra-headers
          `(("Content-Type" . "application/json")
            ("Authorization" . ,(concat "Bearer " api-key))))
         (url-request-data (ob-prompt--json-encode-ascii payload))
         (buffer (url-retrieve-synchronously endpoint t t 300)))
    (unless buffer
      (user-error "ob-prompt: request failed (no response buffer)"))
    (with-current-buffer buffer
      (unwind-protect
          (progn
            (goto-char (point-min))
            ;; Check HTTP status
            (unless (looking-at "HTTP/.* 200")
              (re-search-forward "^HTTP/.* \\([0-9]+\\)" nil t)
              (user-error "ob-prompt: HTTP error %s"
                          (match-string 1)))
            ;; Skip headers
            (re-search-forward "\r?\n\r?\n" nil 'move)
            (decode-coding-string
             (buffer-substring-no-properties (point) (point-max))
             'utf-8))
        (kill-buffer buffer)))))

8. CLI Backend

When :command is present on a block, ob-prompt switches to CLI mode. Instead of constructing an HTTP request, it pipes the prompt text (after variable and noweb expansion) to the shell command via stdin and captures stdout as the response.

This makes any CLI tool that reads a prompt from stdin an LLM backend such as claude code, a custom wrapper script, or even cat for testing:

#+begin_src prompt :command "cat"
Explain monads in simple terms.
#+end_src

#+RESULTS:
#+begin_assistant
<context>
Document content thus far...
</context>

Explain monads in simple terms.
#+end_assistant

8.1. Building CLI Input

The CLI transport receives plain text rather than a JSON payload. When :preamble yes is set, the buffer context is wrapped in <context> tags and prepended to the body — the same convention used by the HTTP backend, so the LLM sees a consistent format regardless of transport.

(defun ob-prompt--build-cli-input (body &optional preamble-text)
  "Build the plain-text input string for CLI transport.
BODY is the user prompt.  PREAMBLE-TEXT is prepended wrapped in
context tags if non-nil."
  (if preamble-text
      (format "<context>\n%s\n</context>\n\n%s" preamble-text body)
    body))

8.2. Shell Transport

(defun ob-prompt--request-cli (command input)
  "Send INPUT to shell COMMAND via stdin, return stdout.
Signals `user-error' on non-zero exit."
  (with-temp-buffer
    (let ((exit-code (call-process-region
                      input nil shell-file-name nil t nil
                      shell-command-switch command)))
      (unless (eq exit-code 0)
        (user-error "ob-prompt: command failed (exit %s): %s"
                    exit-code
                    (string-trim (buffer-string))))
      (string-trim-right (buffer-string)))))

9. The Core Execution Function

This is what Babel calls when you press C-c C-c on a prompt block. It is a thin orchestrator — all logic lives in the helpers above. The :command header argument selects the transport: when present, the CLI backend is used; otherwise, the HTTP backend sends to :endpoint.

(defun org-babel-execute:prompt (body params)
  "Execute a prompt BODY with PARAMS via Org Babel.
Sends the prompt to the configured LLM endpoint and returns
the response text."
  (let* ((model    (cdr (assq :model params)))
         (endpoint (cdr (assq :endpoint params)))
         (api-key  (cdr (assq :api-key params)))
         (system   (cdr (assq :system params)))
         (preamble (cdr (assq :preamble params)))
         (debug    (cdr (assq :debug params)))
         (command  (cdr (assq :command params)))
         ;; Gather buffer text above this src block when :preamble is set
         (preamble-text
          (when (equal preamble "yes")
            (let ((src-block (org-element-at-point)))
              (buffer-substring-no-properties
               (point-min)
               (org-element-property :begin src-block)))))
         ;; Expand :var references in the body
         (expanded-body (org-babel-expand-body:prompt body params)))
    (if command
        ;; CLI mode — pipe plain text to command, return stdout directly
        (let ((input (ob-prompt--build-cli-input
                      expanded-body preamble-text)))
          (ob-prompt--request-cli command input))
      ;; HTTP mode — build JSON payload, call API
      (let* ((messages (ob-prompt--build-messages
                        expanded-body system preamble-text))
             (payload (ob-prompt--build-payload model messages)))
        (if (equal debug "yes")
            (ob-prompt--format-debug endpoint api-key payload)
          (ob-prompt--parse-response
           (ob-prompt--request endpoint api-key payload)))))))

10. Debug Mode

When :debug yes is set, instead of calling the API we return a human-readable dump of what would be sent. The API key is redacted. This only applies to the HTTP backend — the CLI backend does not use debug mode since you can simply echo stdin in your command to inspect it.

(defun ob-prompt--format-debug (endpoint api-key payload)
  "Format a debug representation of the request.
Redacts API-KEY to show only the first and last 4 characters."
  (let ((redacted (if (and api-key (> (length api-key) 8))
                      (concat (substring api-key 0 4)
                              "..."
                              (substring api-key -4))
                    "****")))
    (format "endpoint: %s\napi-key:  %s\npayload:\n%s"
            endpoint redacted
            (json-encode payload))))

11. Session Support (stub)

Babel requires org-babel-prep-session:LANG to exist. Full multi-turn session support (maintaining conversation history across blocks) is a future enhancement.

(defun org-babel-prep-session:prompt (_session _params)
  "Prepare a prompt session.
Prompt sessions are not yet supported."
  (error "ob-prompt: sessions are not yet implemented"))

12. Running Tests

The full ERT test suite lives in ob-prompt-test.el (not tangled from this file). Run it from the command line:

emacs -batch -L . -l ob-prompt-test.el -f ert-run-tests-batch-and-exit 2>&1
Running 21 tests (2026-02-20 21:01:52-0800, selector ‘t’)
   passed   1/21  ob-prompt-debug-no-calls-api (0.000096 sec)
   passed   2/21  ob-prompt-debug-redacts-key (0.000024 sec)
   passed   3/21  ob-prompt-debug-short-key (0.000015 sec)
   passed   4/21  ob-prompt-execute-debug-skips-http (0.000039 sec)
   passed   5/21  ob-prompt-execute-round-trip (0.000045 sec)
   passed   6/21  ob-prompt-expand-body-no-vars (0.000012 sec)
   passed   7/21  ob-prompt-expand-body-substitutes-vars (0.000016 sec)
   passed   8/21  ob-prompt-json-encode-ascii-escapes-non-ascii (0.000029 sec)
   passed   9/21  ob-prompt-messages-body-only (0.000011 sec)
   passed  10/21  ob-prompt-messages-nil-system-ignored (0.000011 sec)
   passed  11/21  ob-prompt-messages-system-and-preamble (0.000013 sec)
   passed  12/21  ob-prompt-messages-with-preamble (0.000016 sec)
   passed  13/21  ob-prompt-messages-with-system (0.000015 sec)
   passed  14/21  ob-prompt-parse-api-error (0.000026 sec)
   passed  15/21  ob-prompt-parse-malformed-json (0.000029 sec)
   passed  16/21  ob-prompt-parse-unexpected-structure (0.000020 sec)
   passed  17/21  ob-prompt-parse-valid-response (0.000016 sec)
   passed  18/21  ob-prompt-payload-basic (0.000012 sec)
   passed  19/21  ob-prompt-preamble-no-disables-context (0.000031 sec)
   passed  20/21  ob-prompt-request-decodes-utf8-response (0.000041 sec)
   passed  21/21  ob-prompt-request-sends-auth-header (0.000037 sec)

Ran 21 tests, 21 results as expected, 0 unexpected (2026-02-20 21:01:52-0800, 0.000822 sec)

Or interactively from within Emacs:

(load-file "ob-prompt-test.el")
(ert-run-tests-batch "^ob-prompt-")

13. Example Usage

13.1. Basic prompt

#+begin_src prompt :results raw
How do LLMs improve literate programming?
#+end_src
How do LLMs improve literate programming?

LLMs improve literate programming mainly by tightening the feedback loop between explanation and implementation.

Concretely:

  • They act as an always-available reviewer, questioning design choices and spotting inconsistencies between prose and code.
  • They help draft, refine, and align narrative explanations with the actual behavior of the code.
  • They make examples "live": you can regenerate explanations or variations directly from the source document.
  • They reduce friction in exploratory writing, letting you iterate on structure and wording without breaking flow.

In short, LLMs turn literate programming from a static exposition into an interactive dialogue, while keeping the document itself as the single source of truth.

13.2. With a system prompt

#+begin_src prompt :system "You are a concise technical writer. Who speaks like a pirate..." :results raw
How does emacs org mode support literate programming?
#+end_src
How does emacs org mode support literate programming?

Arrr, listen close, matey:

Emacs Org mode be a fine vessel for literate programmin' because it lets ye weave code and prose into one sturdy logbook.

In plain seaspeak:

  • Ye write explanations, design notes, and code side by side in one Org file.
  • Code lives in Babel src blocks, which can be tangled into real source files or executed right there on deck.
  • Results of execution come back into the document, keepin' the tale honest and reproducible.
  • Multiple languages sail together — Lisp, Python, shell, and more — all in one chart.
  • Export turns the same file into HTML, PDF, or plain text, without losin' the narrative.

So Org mode ain't just holdin' code — it tells the story of why the code exists, and proves it works. A proper captain's log, savvy? ☠️

13.3. With variables from other blocks

Org Mode's noweb feature lets you reference other code blocks from within a prompt block. This makes it easy to do things like ask the LLM to review or explain previously defined code.

To use it, enable :noweb yes in the prompt block header and insert references to named blocks using <<name>>. When the block is executed, those references are expanded in place, and the resolved content is sent as part of the prompt.

#+name: my-code
#+begin_src python :exports none :python python3
def fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a
#+end_src

#+begin_src prompt :var code=my-code :results raw :preamble no :noweb yes :wrap example
Review this code and suggest improvements:

<<my-code>>
#+end_src
Review this code and suggest improvements: (output your results in normal text format). Make sure to reference specific parts of the code / lines

def fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a
Here is a brief review with specific suggestions:

1. **Function signature (line 1)**
   - The function does not specify expected input types or behavior for edge cases.
   - Improvement: Add a docstring describing what `n` represents (e.g., zero-based index), expected type (`int`), and return value.
   - Consider type hints: `def fibonacci(n: int) -> int:`.

2. **Input validation (line 1–2)**
   - The function assumes `n` is a non-negative integer.
   - Improvement: Explicitly check for invalid inputs (e.g., negative values or non-integers) and raise a `ValueError` or `TypeError`.

3. **Initialization clarity (line 2)**
   - `a, b = 0, 1` is correct but implicit.
   - Improvement: Add a short comment explaining that `a` and `b` track consecutive Fibonacci numbers for readability, especially for less experienced readers.

4. **Loop construct (line 3)**
   - `for _ in range(n):` is idiomatic and efficient.
   - Improvement: None required, but you could rename `_` to something like `i` if step-wise debugging or clarity is desired.

5. **Return value semantics (line 5)**
   - The function returns `a`, which corresponds to `F(n)` with `F(0) = 0`.
   - Improvement: Explicitly document this convention in the docstring, as some definitions use different starting indices.

6. **Testing and examples (missing)**
   - There are no usage examples.
   - Improvement: Include example calls in a docstring or separate test cases to clarify expected behavior (e.g., `fibonacci(0) == 0`, `fibonacci(1) == 1`).

13.4. Using :preamble for document context

When :preamble yes is set, the entire buffer content above the block is sent as context, wrapped in <context> tags. This is powerful for literate documents where earlier sections inform later prompts:

#+begin_src prompt :preamble yes :results drawer
Given the code and discussion above, what edge cases are missing?
#+end_src

13.5. Using :command for CLI tools

Any CLI tool that reads from stdin can serve as an LLM backend. This is useful for tools like claude code, or custom wrapper scripts:

#+begin_src prompt :command "llm -m claude-sonnet-4-6"
Explain monads in simple terms.
#+end_src

The :preamble mechanism works identically — when set to yes, buffer content above the block is prepended in <context> tags. The :system header argument is not used in CLI mode; pass system prompt flags directly in the command string if your tool supports them.

14. File Assembly

Every Emacs Lisp package needs a standard file header (library commentary, dependency declarations) and a closing provide form. Rather than clutter the top of the document with boilerplate, we define them here and let noweb compose the final ob-prompt.el in the correct order.

14.1. File header

;;; ob-prompt.el --- Org Babel functions for LLM prompts -*- lexical-binding: t; -*-

;; Copyright (C) 2026 Pablo Munoz

;; Author: Pablo Munoz <contact@slashpablo.com>
;; Maintainer: Pablo Munoz <contact@slashpablo.com>
;; URL: https://github.com/slashpablo/ob-prompt
;; Version: 0.1.0
;; Package-Requires: ((emacs "28.1") (org "9.7"))
;; Keywords: org, babel, ai, llm

;; This file is part of ob-prompt.
;;
;; ob-prompt is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;;
;; ob-prompt is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with ob-prompt.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Org Babel support for executing LLM prompts as src blocks.
;;
;; Usage:
;;   #+begin_src prompt :model claude-sonnet-4-5-20250929 :results raw
;;   Explain monads in simple terms.
;;   #+end_src
;;
;; Press C-c C-c on the block to send the prompt to an LLM
;; and insert the response as a #+RESULTS: block.

;;; Code:

(require 'ob)
(require 'org-element)
(require 'org-macs)
(require 'json)
(require 'url)

14.2. Provide

(provide 'ob-prompt)

;;; ob-prompt.el ends here

14.3. Tangled output

This single block tangles into ob-prompt.el. The noweb references pull in every named block in the order an Emacs Lisp package expects: header, declarations, implementation, provide.

;;; ob-prompt.el --- Org Babel functions for LLM prompts -*- lexical-binding: t; -*-

;; Copyright (C) 2026 Pablo Munoz

;; Author: Pablo Munoz <contact@slashpablo.com>
;; Maintainer: Pablo Munoz <contact@slashpablo.com>
;; URL: https://github.com/slashpablo/ob-prompt
;; Version: 0.1.0
;; Package-Requires: ((emacs "28.1") (org "9.7"))
;; Keywords: org, babel, ai, llm

;; This file is part of ob-prompt.
;;
;; ob-prompt is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;;
;; ob-prompt is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with ob-prompt.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Org Babel support for executing LLM prompts as src blocks.
;;
;; Usage:
;;   #+begin_src prompt :model claude-sonnet-4-5-20250929 :results raw
;;   Explain monads in simple terms.
;;   #+end_src
;;
;; Press C-c C-c on the block to send the prompt to an LLM
;; and insert the response as a #+RESULTS: block.

;;; Code:

(require 'ob)
(require 'org-element)
(require 'org-macs)
(require 'json)
(require 'url)

(defconst org-babel-header-args:prompt
  '((model    . :any)   ;; LLM model identifier
    (system   . :any)   ;; system prompt string
    (endpoint . :any)   ;; API endpoint URL
    (api-key  . :any)   ;; API key (string or evaluated form)
    (preamble . :any)   ;; include buffer content above block as context
    (command  . :any)   ;; shell command for CLI backend (reads stdin)
    (debug    . :any))  ;; return raw request/response instead of content
  "Prompt-specific header arguments for ob-prompt.")

(defvar org-babel-default-header-args:prompt
  '((:results . "raw")
    (:exports . "both") ;; Ensures both prompt and result are exported
    (:eval . "no-export") ;; Do not re-evaluate prompt blocks on export
    (:wrap . "assistant") ;; Provide concrete boundries to responses
    (:noweb . "yes")) ;; Enable <<block-name>> inclusion by default
  "Default header arguments for prompt src blocks.")

(defun org-babel-expand-body:prompt (body params)
  "Expand BODY for a prompt block by substituting :var references.
Each :var binding replaces $name in BODY with the variable's value."
  (let ((expanded body))
    (dolist (pair (org-babel--get-vars params) expanded)
      (setq expanded
            (replace-regexp-in-string
             (regexp-quote (format "$%s" (car pair)))
             (save-match-data (format "%s" (cdr pair)))
             expanded
             t t)))))

(defun ob-prompt--build-messages (body &optional system preamble-text)
  "Build the messages vector for the API request.
BODY is the user's prompt text.
SYSTEM, when non-nil, becomes a system message.
PREAMBLE-TEXT, when non-nil, is wrapped in <context> tags
and prepended to the user message."
  (let ((user-content (if preamble-text
                          (format "<context>\n%s\n</context>\n\n%s"
                                  preamble-text body)
                        body))
        (messages '()))
    (when system
      (push `((role . "system") (content . ,system)) messages))
    (push `((role . "user") (content . ,user-content)) messages)
    (vconcat (nreverse messages))))

(defun ob-prompt--build-payload (model messages)
  "Build the JSON-ready alist for the API request.
MODEL is the model identifier string.
MESSAGES is the vector returned by `ob-prompt--build-messages'."
  `(("model" . ,model)
    ("max_completion_tokens" . 16384)
    ("messages" . ,messages)))

(defun ob-prompt--parse-response (json-string)
  "Extract the assistant's message content from JSON-STRING.
Signals `user-error' if the response indicates an API error
or has an unexpected structure."
  (let* ((data (condition-case err
                   (json-read-from-string json-string)
                 (json-error
                  (user-error "ob-prompt: failed to parse response JSON: %s"
                              (error-message-string err)))))
         (err-msg (alist-get 'message (alist-get 'error data))))
    (when err-msg
      (user-error "ob-prompt: API error: %s" err-msg))
    (condition-case nil
        (alist-get 'content
                   (alist-get 'message
                              (aref (alist-get 'choices data) 0)))
      (error
       (user-error "ob-prompt: unexpected response structure: %s"
                   (truncate-string-to-width json-string 200))))))

(defun ob-prompt--json-encode-ascii (object)
  "Encode OBJECT as JSON with all non-ASCII characters escaped to \\uNNNN.
This avoids `url-http-create-request' failing with
\"Multibyte text in HTTP request\" (Emacs Bug#23750)."
  (let ((json (json-encode object)))
    (replace-regexp-in-string
     "[^\x00-\x7f]"
     (lambda (char)
       (format "\\u%04x" (string-to-char char)))
     json nil t)))

(defun ob-prompt--request (endpoint api-key payload)
  "Send PAYLOAD to ENDPOINT using API-KEY via built-in `url'.
Returns the response body as a string.
Signals `user-error' on HTTP or transport errors."
  (let* ((url-request-method "POST")
         (url-request-extra-headers
          `(("Content-Type" . "application/json")
            ("Authorization" . ,(concat "Bearer " api-key))))
         (url-request-data (ob-prompt--json-encode-ascii payload))
         (buffer (url-retrieve-synchronously endpoint t t 300)))
    (unless buffer
      (user-error "ob-prompt: request failed (no response buffer)"))
    (with-current-buffer buffer
      (unwind-protect
          (progn
            (goto-char (point-min))
            ;; Check HTTP status
            (unless (looking-at "HTTP/.* 200")
              (re-search-forward "^HTTP/.* \\([0-9]+\\)" nil t)
              (user-error "ob-prompt: HTTP error %s"
                          (match-string 1)))
            ;; Skip headers
            (re-search-forward "\r?\n\r?\n" nil 'move)
            (decode-coding-string
             (buffer-substring-no-properties (point) (point-max))
             'utf-8))
        (kill-buffer buffer)))))

(defun ob-prompt--build-cli-input (body &optional preamble-text)
  "Build the plain-text input string for CLI transport.
BODY is the user prompt.  PREAMBLE-TEXT is prepended wrapped in
context tags if non-nil."
  (if preamble-text
      (format "<context>\n%s\n</context>\n\n%s" preamble-text body)
    body))

(defun ob-prompt--request-cli (command input)
  "Send INPUT to shell COMMAND via stdin, return stdout.
Signals `user-error' on non-zero exit."
  (with-temp-buffer
    (let ((exit-code (call-process-region
                      input nil shell-file-name nil t nil
                      shell-command-switch command)))
      (unless (eq exit-code 0)
        (user-error "ob-prompt: command failed (exit %s): %s"
                    exit-code
                    (string-trim (buffer-string))))
      (string-trim-right (buffer-string)))))

(defun org-babel-execute:prompt (body params)
  "Execute a prompt BODY with PARAMS via Org Babel.
Sends the prompt to the configured LLM endpoint and returns
the response text."
  (let* ((model    (cdr (assq :model params)))
         (endpoint (cdr (assq :endpoint params)))
         (api-key  (cdr (assq :api-key params)))
         (system   (cdr (assq :system params)))
         (preamble (cdr (assq :preamble params)))
         (debug    (cdr (assq :debug params)))
         (command  (cdr (assq :command params)))
         ;; Gather buffer text above this src block when :preamble is set
         (preamble-text
          (when (equal preamble "yes")
            (let ((src-block (org-element-at-point)))
              (buffer-substring-no-properties
               (point-min)
               (org-element-property :begin src-block)))))
         ;; Expand :var references in the body
         (expanded-body (org-babel-expand-body:prompt body params)))
    (if command
        ;; CLI mode — pipe plain text to command, return stdout directly
        (let ((input (ob-prompt--build-cli-input
                      expanded-body preamble-text)))
          (ob-prompt--request-cli command input))
      ;; HTTP mode — build JSON payload, call API
      (let* ((messages (ob-prompt--build-messages
                        expanded-body system preamble-text))
             (payload (ob-prompt--build-payload model messages)))
        (if (equal debug "yes")
            (ob-prompt--format-debug endpoint api-key payload)
          (ob-prompt--parse-response
           (ob-prompt--request endpoint api-key payload)))))))

(defun ob-prompt--format-debug (endpoint api-key payload)
  "Format a debug representation of the request.
Redacts API-KEY to show only the first and last 4 characters."
  (let ((redacted (if (and api-key (> (length api-key) 8))
                      (concat (substring api-key 0 4)
                              "..."
                              (substring api-key -4))
                    "****")))
    (format "endpoint: %s\napi-key:  %s\npayload:\n%s"
            endpoint redacted
            (json-encode payload))))

(defun org-babel-prep-session:prompt (_session _params)
  "Prepare a prompt session.
Prompt sessions are not yet supported."
  (error "ob-prompt: sessions are not yet implemented"))

(provide 'ob-prompt)

;;; ob-prompt.el ends here

Author: pablo

Created: 2026-02-20 Fri 21:01

Validate