[Feature] Standardized CLI Interface for API Interaction (JSON-RPC over Stdin) #2832

Closed
opened 2026-02-28 20:30:00 -06:00 by GiteaMirror · 5 comments
Owner

Originally created by @Zeor-Xain on GitHub (Jan 17, 2026).

Verified feature request does not already exist?

  • I have searched and found no existing issue

💻

  • Would you like to implement this feature?

Pitch: what problem are you trying to solve?

The current @actual-app/api is excellent but strictly tied to the Node.js ecosystem. As the documentation explicitly states: "Actual does not expose HTTP endpoints that can be called."

This architectural choice creates a significant barrier for users and developers who work with other languages (Bash scripts, Python, Go, Rust) or environments where a persistent Node.js process or a custom HTTP wrapper server is overkill or technically infeasible.

To interact with Actual programmatically today, a non-Node developer must:

  1. Learn enough JavaScript to write a custom wrapper script.
  2. Manage a Node.js runtime just to bridge data.
  3. Handle the "plumbing" of passing data between their main application (e.g., a Python scraper) and the Node script (often dealing with shell escaping issues or temporary files).

This fragmentation discourages the creation of community tools (like importers, reporters, or automation bots) in languages other than JavaScript. I recently implemented a custom bridge to import transactions from Python via docker/podman exec, and while effective, it felt like reinventing a wheel that could be a standard part of the toolkit.

Describe your ideal solution to this problem

I suggest adding a Standardized CLI mode to the @actual-app/api package (or a companion package) that implements a simple "JSON-RPC over Stdin" interface.

The Mechanism

Instead of writing custom JS scripts for every task, the user would invoke a standard entry point (e.g., actual-cli run-bridge) that:

  1. Reads a JSON object from STDIN.
  2. Executes the corresponding API method.
  3. Writes the result as a JSON object to STDOUT.
  4. Writes logs/debug info to STDERR (crucial for keeping the data pipe clean).

Example Workflow

A Python script (or any language) could simply spawn the process and pipe data:

Input (STDIN):

{
  "command": "importTransactions",
  "config": {
    "serverURL": "http://localhost:5006",
    "password": "..."
  },
  "params": {
    "syncId": "budget-uuid",
    "accountId": "account-uuid",
    "transactions": [...]
  }
}

Output (STDOUT):

{
  "status": "success",
  "data": {
    "added": ["trans-id-1"],
    "updated": [],
    "errors": []
  }
}

Why "JSON over Stdin"?

  • Universal Compatibility: Every programming language can spawn a subprocess and write to stdin/read from stdout.
  • Security: No need to open extra HTTP ports or manage tokens for a local server; it relies on process ownership.
  • Simplicity: It avoids the complexity of setting up a REST/GraphQL layer just for local automation.
  • Atomic: Perfect for "one-off" tasks (like a daily cron job import) or containerized workflows (e.g., docker exec -i actual-node actual-cli < payload.json).

Teaching and learning

Discoverability

This feature positions Actual Budget as "Language Agnostic" for automation. Documentation could feature a "Universal API Usage" section showing examples in Bash, Python and Go, all calling the same underlying actual-cli command.

Documentation Needs

  • Schema Definition: A clear reference of the expected JSON structure for input and output.
  • Separation of Streams: Explicit warnings that consumers must listen to STDOUT for data and STDERR for logs (to avoid parsing errors if debug logs pollute the output).
  • Examples: A "Cookbook" showing how to wrap this CLI in various languages.

Potential Pitfalls

  • Startup Time: Initializing the API (loading the budget database) takes a moment. For bulk operations (like importing 100 transactions), this is negligible. For high-frequency, single-transaction calls, the overhead might be noticeable, but acceptable for background automation.
  • Data Isolation: Users need to understand that the CLI acts on the local version of the budget (downloading/syncing), similar to how the Node API works today.

Prototype use case

  • Actual Budget installed in a local Podman container via the provided Actual YAML file based on node:lts-slim.
  • A second container, ActualAPI, running side-by-side with the actual_actual_server_1 container, configured to use the server container's network and with the data directory mounted at /data.
  • The ActualAPI container (npm install --location=global @actual-app/api), serving with a custom bridge.js for @actual-app/api actions.
/bridge.js (expand)
const api = require('@actual-app/api');

// Reads all input from stdin (the JSON that Python will send)
const readStdin = () => {
  return new Promise((resolve, reject) => {
    let data = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', chunk => data += chunk);
    process.stdin.on('end', () => {
      try {
        resolve(JSON.parse(data));
      } catch (e) {
        reject(new Error("Error reading JSON from stdin: " + e.message));
      }
    });
    process.stdin.on('error', reject);
  });
};

(async () => {
  try {
    const input = await readStdin();

    // Configuration based on input or your environment defaults
    // Python can send these values, or we use the defaults from your script.js
    const config = {
      dataDir: '/data',
      serverURL: input.serverURL || 'http://localhost:5006',
      password: input.password || '123',
    };

    // 1. Initialize
    await api.init(config);

    // 2. Download Budget
    // input.syncId is required. filePassword is optional (for E2E encryption)
    await api.downloadBudget(input.syncId, { password: input.filePassword });

    // 3. Execute the requested action
    let result;
    if (input.action === 'importTransactions') {
      result = await api.importTransactions(input.accountId, input.transactions);
    } else if (input.action === 'getAccounts') {
      result = await api.getAccounts();
    } else {
      throw new Error(`Unknown action: ${input.action}`);
    }

    // 4. Return the result to Python
    await api.shutdown();
    console.log(JSON.stringify({ status: "success", data: result }));

  } catch (error) {
    // Ensures that Python receives a formatted error
    console.error(JSON.stringify({ status: "error", message: error.message, stack: error.stack }));
    process.exit(1);
  }
})();

Can run with success passing a json payload to stdin in terminal:

echo '{"action": "importTransactions", 
"syncId": "4a6c8717-a281-4fde-afbe-966a986b5197", 
"accountId": "1f1db937-0dd5-4f9c-9661-4c04c610585a",
"transactions": [{"date": "2022-10-02", "amount": 1204, "payee_name": "Kroger", "imported_id": "terminal-test-001"}]}' | node /bridge.js
Originally created by @Zeor-Xain on GitHub (Jan 17, 2026). ### Verified feature request does not already exist? - [x] I have searched and found no existing issue ### 💻 - [ ] Would you like to implement this feature? ### Pitch: what problem are you trying to solve? The current `@actual-app/api` is excellent but strictly tied to the Node.js ecosystem. As the documentation explicitly states: *"Actual does not expose HTTP endpoints that can be called."* This architectural choice creates a significant barrier for users and developers who work with other languages (Bash scripts, Python, Go, Rust) or environments where a persistent Node.js process or a custom HTTP wrapper server is overkill or technically infeasible. To interact with Actual programmatically today, a non-Node developer must: 1. Learn enough JavaScript to write a custom wrapper script. 2. Manage a Node.js runtime just to bridge data. 3. Handle the "plumbing" of passing data between their main application (e.g., a Python scraper) and the Node script (often dealing with shell escaping issues or temporary files). This fragmentation discourages the creation of community tools (like importers, reporters, or automation bots) in languages other than JavaScript. I recently implemented a custom bridge to import transactions from Python via `docker/podman exec`, and while effective, it felt like reinventing a wheel that could be a standard part of the toolkit. ### Describe your ideal solution to this problem I suggest adding a **Standardized CLI mode** to the `@actual-app/api` package (or a companion package) that implements a simple **"JSON-RPC over Stdin"** interface. ### The Mechanism Instead of writing custom JS scripts for every task, the user would invoke a standard entry point (e.g., `actual-cli run-bridge`) that: 1. Reads a JSON object from **STDIN**. 2. Executes the corresponding API method. 3. Writes the result as a JSON object to **STDOUT**. 4. Writes logs/debug info to **STDERR** (crucial for keeping the data pipe clean). ### Example Workflow A Python script (or any language) could simply spawn the process and pipe data: **Input (STDIN):** ```json { "command": "importTransactions", "config": { "serverURL": "http://localhost:5006", "password": "..." }, "params": { "syncId": "budget-uuid", "accountId": "account-uuid", "transactions": [...] } } ``` **Output (STDOUT):** ```json { "status": "success", "data": { "added": ["trans-id-1"], "updated": [], "errors": [] } } ``` ### Why "JSON over Stdin"? * **Universal Compatibility:** Every programming language can spawn a subprocess and write to stdin/read from stdout. * **Security:** No need to open extra HTTP ports or manage tokens for a local server; it relies on process ownership. * **Simplicity:** It avoids the complexity of setting up a REST/GraphQL layer just for local automation. * **Atomic:** Perfect for "one-off" tasks (like a daily cron job import) or containerized workflows (e.g., `docker exec -i actual-node actual-cli < payload.json`). ### Teaching and learning ### Discoverability This feature positions Actual Budget as "Language Agnostic" for automation. Documentation could feature a **"Universal API Usage"** section showing examples in Bash, Python and Go, all calling the same underlying `actual-cli` command. ### Documentation Needs * **Schema Definition:** A clear reference of the expected JSON structure for input and output. * **Separation of Streams:** Explicit warnings that consumers must listen to `STDOUT` for data and `STDERR` for logs (to avoid parsing errors if debug logs pollute the output). * **Examples:** A "Cookbook" showing how to wrap this CLI in various languages. ### Potential Pitfalls * **Startup Time:** Initializing the API (loading the budget database) takes a moment. For bulk operations (like importing 100 transactions), this is negligible. For high-frequency, single-transaction calls, the overhead might be noticeable, but acceptable for background automation. * **Data Isolation:** Users need to understand that the CLI acts on the *local* version of the budget (downloading/syncing), similar to how the Node API works today. --- ### Prototype use case - Actual Budget installed in a local Podman [container](https://actualbudget.org/docs/install/docker) via the provided Actual [YAML file](https://github.com/actualbudget/actual/blob/6ed18d8f8c733fd4f0630a6240350d3e0e79cd6a/packages/sync-server/docker-compose.yml) [based on node:lts-slim](https://hub.docker.com/_/node). - A second container, `ActualAPI`, running side-by-side with the `actual_actual_server_1` container, configured to use the server container's network and with the data directory mounted at `/data`. - The `ActualAPI` container (`npm install --location=global @actual-app/api`), serving with a custom `bridge.js` for `@actual-app/api` actions. <details> <summary>/bridge.js (expand)</summary> ```javascript const api = require('@actual-app/api'); // Reads all input from stdin (the JSON that Python will send) const readStdin = () => { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => data += chunk); process.stdin.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(new Error("Error reading JSON from stdin: " + e.message)); } }); process.stdin.on('error', reject); }); }; (async () => { try { const input = await readStdin(); // Configuration based on input or your environment defaults // Python can send these values, or we use the defaults from your script.js const config = { dataDir: '/data', serverURL: input.serverURL || 'http://localhost:5006', password: input.password || '123', }; // 1. Initialize await api.init(config); // 2. Download Budget // input.syncId is required. filePassword is optional (for E2E encryption) await api.downloadBudget(input.syncId, { password: input.filePassword }); // 3. Execute the requested action let result; if (input.action === 'importTransactions') { result = await api.importTransactions(input.accountId, input.transactions); } else if (input.action === 'getAccounts') { result = await api.getAccounts(); } else { throw new Error(`Unknown action: ${input.action}`); } // 4. Return the result to Python await api.shutdown(); console.log(JSON.stringify({ status: "success", data: result })); } catch (error) { // Ensures that Python receives a formatted error console.error(JSON.stringify({ status: "error", message: error.message, stack: error.stack })); process.exit(1); } })(); ``` Can run with success passing a json payload to `stdin` in terminal: ```bash echo '{"action": "importTransactions", "syncId": "4a6c8717-a281-4fde-afbe-966a986b5197", "accountId": "1f1db937-0dd5-4f9c-9661-4c04c610585a", "transactions": [{"date": "2022-10-02", "amount": 1204, "payee_name": "Kroger", "imported_id": "terminal-test-001"}]}' | node /bridge.js ``` </details>
GiteaMirror added the needs votesfeature labels 2026-02-28 20:30:00 -06:00
Author
Owner

@github-actions[bot] commented on GitHub (Jan 17, 2026):

Thanks for sharing your idea!

This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution).

The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+

Don't forget to upvote the top comment with 👍!

@github-actions[bot] commented on GitHub (Jan 17, 2026): :sparkles: Thanks for sharing your idea! :sparkles: This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution). The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+ Don't forget to upvote the top comment with 👍! <!-- feature-auto-close-comment -->
Author
Owner

@MatissJanis commented on GitHub (Jan 17, 2026):

👋 Feel free to implement this and we'll be happy to link to it in the community projects page: https://actualbudget.org/docs/community-repos

@MatissJanis commented on GitHub (Jan 17, 2026): 👋 Feel free to implement this and we'll be happy to link to it in the community projects page: https://actualbudget.org/docs/community-repos
Author
Owner

@Zeor-Xain commented on GitHub (Jan 17, 2026):

TL;DR - Feature Request: Actual CLI (JSON-RPC)

The Problem:
The current Actual Budget API is an NPM package exclusive to Node.js. This forces developers using other languages (Python, Go, Rust, Bash) to
either write custom wrappers or learn JavaScript just to move data.

The Solution:
Implement an official Command Line Interface (CLI) that operates via STDIN/STDOUT using JSON.

How it would work:

  1. Input: You pipe a JSON object into the command (e.g., actual-cli run).
  2. Processing: The CLI executes the requested API function (Import, Get Accounts, etc.).
  3. Output: The result is returned as a clean JSON object.

Key Benefits:

  • Language Agnostic: Every programming language can easily read from and write to the terminal.
  • Secure: No need to open extra HTTP ports; it works locally at the process level.
  • Simple: Removes the need for maintaining custom Node.js scripts or intermediate wrapper servers.
@Zeor-Xain commented on GitHub (Jan 17, 2026): TL;DR - Feature Request: Actual CLI (JSON-RPC) The Problem: The current Actual Budget API is an NPM package exclusive to Node.js. This forces developers using other languages (Python, Go, Rust, Bash) to either write custom wrappers or learn JavaScript just to move data. The Solution: Implement an official Command Line Interface (CLI) that operates via STDIN/STDOUT using JSON. How it would work: 1. Input: You pipe a JSON object into the command (e.g., actual-cli run). 2. Processing: The CLI executes the requested API function (Import, Get Accounts, etc.). 3. Output: The result is returned as a clean JSON object. Key Benefits: * Language Agnostic: Every programming language can easily read from and write to the terminal. * Secure: No need to open extra HTTP ports; it works locally at the process level. * Simple: Removes the need for maintaining custom Node.js scripts or intermediate wrapper servers.
Author
Owner

@Zeor-Xain commented on GitHub (Jan 17, 2026):

I understand that the current model, focused on Node.js via @actual-app/api, is excellent for the primary use case:
Custom importers and exporters.

However, the accessibility of the API would improve significantly by adding direct Shell communication.

A "JSON-RPC over Stdin" approach seems to align well with this requirement, being both agnostic and straightforward. This covers the most elementary use case: a Shell pipeline designed to receive a return value.

The inability of the @actual-app/api to interact with the Shell in a pipeline seems to be a fundamental gap in its current architecture.

The choice of "JSON-RPC over Stdin" fits the current API structure well:

  • Part of the functionality already utilizes JSON.
  • Exclusive exposure to STDIN eliminates the complexity of configuring REST/GraphQL layers just for automation, while also avoiding the need for additional HTTP ports and token management.

In fact, the current "Code-level API" is quite restrictive, and a significant gap. Communicating with the shell would expand the API to also be IPC-based. Therefore, this feature seems appropriate to reside within the official API package, unlike community projects that merely build upon it.

Residing in the official package also brings benefits in security and maintenance, but above all, in communication standardization.

I believe the implementation of this feature addresses these challenges with an effort that is consistent with the benefits.

Question for the maintainers:

  1. Does this make sense to be considered in the ActualBudget Roadmap?

I am happy to provide a functional prototype, even though I am unable to join the maintainers at this time:

// bridge.js
const api = require('@actual-app/api');

// Reads all input from stdin (the JSON)
const readStdin = () => {
  return new Promise((resolve, reject) => {
    let data = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', chunk => data += chunk);
    process.stdin.on('end', () => {
      try {
        resolve(JSON.parse(data));
      } catch (e) {
        reject(new Error("Error reading JSON from stdin: " + e.message));
      }
    });
    process.stdin.on('error', reject);
  });
};

(async () => {
  try {
    const input = await readStdin();

    // Configuration based on input or your environment defaults
    const config = {
      dataDir: '/data',
      serverURL: input.serverURL || 'http://localhost:5006',
      password: input.password || '123',
    };

    // 1. Initialize
    await api.init(config);

    // 2. Download Budget
    // input.syncId is required. filePassword is optional (for E2E encryption)
    await api.downloadBudget(input.syncId, { password: input.filePassword });

    // 3. Execute the requested action
    let result;
    if (input.action === 'importTransactions') {
      result = await api.importTransactions(input.accountId, input.transactions);
    } else if (input.action === 'getAccounts') {
      result = await api.getAccounts();
    } else {
      throw new Error(`Unknown action: ${input.action}`);
    }

    // 4. Return the result
    await api.shutdown();
    console.log(JSON.stringify({ status: "success", data: result }));

  } catch (error) {
    // Ensures a formatted error
    console.error(JSON.stringify({ status: "error", message: error.message, stack: error.stack }));
    process.exit(1);
  }
})();

This is successfully executed in the shell by passing a JSON payload to standard input (stdin):

node /bridge.js << 'EOF'
{
  "action": "importTransactions",
  "syncId": "4a6c8717-a281-4fde-afbe-966a986b5197",
  "accountId": "1f1db937-0dd5-4f9c-9661-4c04c610585a",
  "transactions": [
    {
      "date": "2022-10-02",
      "amount": 1204,
      "payee_name": "Kroger",
      "imported_id": "terminal-test-001"
    }
  ]
}
EOF
@Zeor-Xain commented on GitHub (Jan 17, 2026): I understand that the current model, focused on Node.js via `@actual-app/api`, is excellent for the primary use case: Custom importers and exporters. However, the accessibility of the API would improve significantly by adding direct Shell communication. A "JSON-RPC over Stdin" approach seems to align well with this requirement, being both agnostic and straightforward. This covers the most elementary use case: a Shell pipeline designed to receive a return value. The inability of the `@actual-app/api` to interact with the Shell in a pipeline seems to be a fundamental gap in its current architecture. The choice of "JSON-RPC over Stdin" fits the current API structure well: - Part of the functionality already utilizes JSON. - Exclusive exposure to STDIN eliminates the complexity of configuring REST/GraphQL layers just for automation, while also avoiding the need for additional HTTP ports and token management. In fact, the current "Code-level API" is quite restrictive, and a significant gap. Communicating with the shell would expand the API to also be IPC-based. Therefore, this feature seems appropriate to reside within the official API package, unlike community projects that merely build upon it. Residing in the official package also brings benefits in security and maintenance, but above all, in communication standardization. I believe the implementation of this feature addresses these challenges with an effort that is consistent with the benefits. Question for the maintainers: 1. Does this make sense to be considered in the ActualBudget Roadmap? --- I am happy to provide a functional prototype, even though I am unable to join the maintainers at this time: ```javascript // bridge.js const api = require('@actual-app/api'); // Reads all input from stdin (the JSON) const readStdin = () => { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => data += chunk); process.stdin.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(new Error("Error reading JSON from stdin: " + e.message)); } }); process.stdin.on('error', reject); }); }; (async () => { try { const input = await readStdin(); // Configuration based on input or your environment defaults const config = { dataDir: '/data', serverURL: input.serverURL || 'http://localhost:5006', password: input.password || '123', }; // 1. Initialize await api.init(config); // 2. Download Budget // input.syncId is required. filePassword is optional (for E2E encryption) await api.downloadBudget(input.syncId, { password: input.filePassword }); // 3. Execute the requested action let result; if (input.action === 'importTransactions') { result = await api.importTransactions(input.accountId, input.transactions); } else if (input.action === 'getAccounts') { result = await api.getAccounts(); } else { throw new Error(`Unknown action: ${input.action}`); } // 4. Return the result await api.shutdown(); console.log(JSON.stringify({ status: "success", data: result })); } catch (error) { // Ensures a formatted error console.error(JSON.stringify({ status: "error", message: error.message, stack: error.stack })); process.exit(1); } })(); ``` This is successfully executed in the shell by passing a JSON payload to standard input (stdin): ``` node /bridge.js << 'EOF' { "action": "importTransactions", "syncId": "4a6c8717-a281-4fde-afbe-966a986b5197", "accountId": "1f1db937-0dd5-4f9c-9661-4c04c610585a", "transactions": [ { "date": "2022-10-02", "amount": 1204, "payee_name": "Kroger", "imported_id": "terminal-test-001" } ] } EOF ```
Author
Owner

@MatissJanis commented on GitHub (Jan 17, 2026):

Does this make sense to be considered in the ActualBudget Roadmap?

No.

But feel free to build this as a separate project in your github account. And then you can share it with the community via the community projects page I linked above.

@MatissJanis commented on GitHub (Jan 17, 2026): > Does this make sense to be considered in the ActualBudget Roadmap? No. But feel free to build this as a separate project in your github account. And then you can share it with the community via the community projects page I linked above.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/actual#2832