A lightweight, program-controlled(Harness) AI automation framework built with Node.js. This framework allows AI to fill specific parts of a workflow while maintaining strict program validation and control.
- Program-Controlled Workflow: AI helps plan and fill parameters, but execution is controlled by program code
- Restricted Planning: Planning uses only known skill names from the skill registry
- Workflow Template Reuse: Approved/reusable workflows are cached in SQLite and reused on future matching requests
- Request Normalization: Variable parts in user requests such as URLs, emails, quoted strings, and numbers can be normalized into reusable templates
- Dynamic Skill Registry: Easily add new capabilities by dropping skill files into the
src/skills/directory - State Machine: Robust task and step status management with SQLite persistence
- Inter-Step Data Flow: Previous step outputs automatically feed into later steps
- Local LLM Integration: Uses Ollama for AI capabilities
- Event Tracking: Comprehensive logging of task and step events
- Admin Workflow Control: Activate, disable, reject, list, or delete saved workflows from CLI
This project is designed around one main principle:
AI can help generate plans, but the program must remain the final controller of execution.
Instead of letting the LLM freely define the entire workflow at runtime every time, this framework:
- Uses AI to select skills from a restricted skill set
- Validates the returned plan in code
- Executes each step in code
- Saves reusable workflow templates into SQLite
- Reuses known-good workflow templates next time without calling the LLM again for planning
This reduces randomness and improves repeatability.
- Task Engine: Orchestrates planning → step building → execution
- Skill Registry: Dynamically loads atomic skills from
src/skills/ - LLM Provider: Handles communication with Ollama API
- Task Repository: SQLite persistence, task/step/event tracking, workflow template storage
- Prompt Builder: Builds prompts for planning and parameter filling
- Validators: Program-side validation of AI outputs before state transitions
Skills are atomic, reusable capabilities. Each skill defines:
stepName: Unique identifierrequiresAI: Whether the skill needs LLM assistancedescription: Human-readable descriptionpayloadDefinition: Expected input payload fieldsexecute(): Implementation functionvalidate(): Output validation function
The framework first tries to find an existing workflow template by normalized request template.
If found:
- Reuse the saved workflow directly if the workflow is active
- If the workflow is inactive, require human to approve or reject the template before proceeding
If not found:
- Call
restrictedPlanningSolution - Ask the LLM to select and order skills using only available skill names
- Save the newly created workflow template with status
inactiveby default whenworkflow.autoActivateisfalse
The framework asks the LLM to fill in the payload for each planned skill using:
- user raw input
- skill payload definition
- prior execution context
For a newly created workflow template, the framework also forms a structured workflow definition to record which parameters for each planned skill can be retrieved from the target input.
After human approval, this structured workflow becomes a guardrail for future requests that fit the same normalized template:
- it confirms the planned skill sequence is correct for this template
- it confirms which skill parameters should be retrievable from this kind of input
- if a future parameter extraction misses a parameter that the approved template says should be present, the framework can retry extraction up to
workflow.fillParameter.maxFillParameterRetryCounttimes based on that guardrail
The program executes steps in order and validates results step by step.
If a workflow was newly generated by restricted planning and passes validation, it is saved as a reusable workflow template. The default value of workflow.autoActivate is false, so the framework creates the workflow template on the first matching request and saves it with status inactive. When a later request fits the same template and the template is still inactive, the framework requires human to approve or reject the template before proceeding. Once approved, the template can be reused automatically for future matching requests. You can change the default behavior via config.json's workflow.autoActivate field.
The final output includes:
taskIdworkflowIdworkflowSourcenormalizedRequestTemplateplansteps
A major part of this framework is workflow template caching.
Example:
User request:
Summarize page www.example.com then send to example@example.com
Normalized template:
Summarize page <URL> then send to <EMAIL>
If this normalized template has already been saved in the database with an active workflow, the framework can skip planning and directly reuse the workflow.
If an active workflow failed during the execution, it will be set to "inactive" status automatically. This can be configured in config.json's workflow.autoInactivate field.
- URL →
<URL> - email →
<EMAIL> - quoted string →
"<STRING>"or'<STRING>' - number →
<NUMBER>
Example:
Convert{id: 123, description: "item description"}into XML format
Normalized:
Convert{id: <NUMBER>, description: "<STRING>"}into XML format
This improves workflow reuse without relying on embeddings or fuzzy semantic matching.
Saved workflows use the following statuses:
active: can be reused automaticallyinactive: temporarily disabled from reuserejected: marked as incorrect and should not be reused
You can also physically delete a workflow record if needed.
- Node.js 18+
- Ollama
- SQLite3
- Clone the repository:
git clone https://github.com/aotol/AI_AUTOMATION.git
cd AI_AUTOMATION- Install dependencies:
npm install- Pull your Ollama model:
ollama pull gemma4:e4b- Edit your configuration:
# update config.json as neededEdit config.json:
{
"llm": {
"provider": "ollama",
"baseUrl": "http://localhost:11434",
"model": "gemma4:e4b",
"timeoutMs": 120000,
"maxOutputTokens": 4000,
"maxInputChars": 10000,
"isReasoningModel": false,
"reasoningStripMode": "auto"
},
"sqlite": {
"path": "data/tasks.db"
},
"input": {
"maxLength": 10000
},
"output": {
"maxLength": 50000
},
"workflow": {
"autoActivate": false,
"autoInactivate": true,
"fillParameter": {
"maxFillParameterRetryCount": 3
}
},
"email": {
"provider": "smtp",
"smtp": {
"name": "AI Automation Framework",
"host": "smtp-host",
"port": 465,
"secure": true,
"auth": {
"user": "your-email@yahoo.com",
"pass": "your-app-password"
}
},
"imap": {
"user": "your-email@yahoo.com",
"pass": "your-app-password",
"host": "imap-host",
"port": 993,
"tls": true
}
}
}- Use app-specific passwords for Email account
- For Gmail, enable 2FA and generate an app password
- The
passfield is used for both SMTP and IMAP authentication
Run a task from CLI:
node ./src/app.js "Fetch https://example.com, extract text, and summarize it"Another example:
node ./src/app.js "Summarize www.example.com then email to example@example.com in Chinese"The output will include workflow information, for example:
{
"taskId": "task_123",
"workflowId": 12,
"workflowSource": "restricted_planning",
"normalizedRequestTemplate": "Summarize <URL> then email to <EMAIL> in Chinese",
"plan": {
"fetch_url": ["url"],
"extract_text_from_html": [],
"summarize_text": [],
"translate_text": ["targetLanguage"],
"send_email": ["address", "subject"]
},
"steps": [
{
"stepName": "fetch_url",
"output": {},
"validation": {}
}
]
}If the same normalized request comes again later and the workflow is still active, the system can reuse it directly from SQLite. If the matching workflow is still inactive, the framework pauses and requires human approval or rejection before proceeding.
node ./src/app.js admin list-workflowsnode ./src/app.js admin activate-workflow 12Internally this sets the workflow status to inactive.
node ./src/app.js admin disable-workflow 12node ./src/app.js admin reject-workflow 12node ./src/app.js admin delete-workflow 12const TaskEngine = require('./src/task-engine');
const TaskRepository = require('./src/task-repository');
const llmProvider = require('./src/llm-provider');
const promptBuilder = require('./src/prompt-builder');
const logger = require('./src/logger');
const { config } = require('./src/config');
(async function () {
const taskRepository = new TaskRepository();
await taskRepository.init();
const services = {
config,
taskRepository,
llmProvider,
promptBuilder,
logger
};
const engine = new TaskEngine(services);
const result = await engine.runTask('Fetch https://example.com and summarize it');
console.log(result);
})();For input:
Fetch https://example.com, extract text, and summarize it
The framework may generate:
{"fetch_url":[],"extract_text_from_html":[],"summarize_text":[]}The framework may fill parameters like:
{
"steps": [
{
"stepIndex": 0,
"stepName": "fetch_url",
"requiresAI": false,
"payload": {
"url": "https://example.com"
}
},
{
"stepIndex": 1,
"stepName": "extract_text_from_html",
"requiresAI": false,
"payload": {}
},
{
"stepIndex": 2,
"stepName": "summarize_text",
"requiresAI": true,
"payload": {
"maxLength": "200 words"
}
}
]
}The program runs each step in sequence and validates outputs.
If this plan is newly generated, the normalized request template, skill sequence, and structured parameter guardrail can be saved into approved_workflow_templates.
Create a new file in src/skills/, for example my_skill.js:
module.exports = {
stepName: 'my_skill',
requiresAI: false,
payloadDefinition: {
text: 'The input text for processing.',
url: 'The URL to fetch.'
},
description: 'Description of what this skill does',
execute: async function (context, services, stepDefinition) {
return { result: 'output' };
},
validate: async function (context, result, stepDefinition) {
const errors = [];
return {
valid: errors.length === 0,
errors
};
}
};The skill will be loaded automatically on next startup.
- Each skill may define a
payloadDefinitionobject buildFillSkillParameterPrompt()uses this to tell the LLM what parameters it should extract- During step building, the LLM fills payload fields based on user input
- If a parameter is not available directly from user input, the step implementation can still derive it from prior step outputs
This keeps the system abstract and avoids hardcoding payload values for every case.
Examples of built-in skills include:
fetch_url: downloads webpage contentextract_text_from_html: extracts readable text from HTMLdetect_language: detects text languagetranslate_text: translates textsummarize_text: summarizes textreceive_email: retrieves latest email from IMAP inboxsend_email: sends email via SMTPsearch_web: performs web searchformat_output: formats final result
The framework uses SQLite for persistence.
Stores the top-level task record.
Stores each step in execution order.
Stores detailed task events for debugging and auditing.
Stores reusable workflow templates:
raw_requestnormalized_request_templateplanned_skillssourcestatuscreated_atupdated_at
src/
├── app.js
├── config.js
├── input-provider.js
├── llm-provider.js
├── logger.js
├── prompt-builder.js
├── skill-utils.js
├── skills.js
├── task-engine.js
├── task-repository.js
├── validators.js
└── skills/
├── fetch_url.js
├── extract_text_from_html.js
└── ...
Test module loading:
node -e "require('./src/skills'); console.log('Modules loaded successfully');"Run a task:
node ./src/app.js "Test task description"List workflows:
node ./src/app.js admin list-workflowsMake sure Ollama is running and the configured URL is correct.
Make sure the skill file exists in src/skills/ and exports a valid stepName.
Make sure the SQLite file path is writable.
- Fork the repository
- Create a feature branch
- Add or improve skills in
src/skills/ - Test thoroughly
- Submit a pull request
MIT License - see LICENSE for details.