Teach plugin creator scheduled task templates

This commit is contained in:
Abhinav Vedmala
2026-06-25 12:27:10 -04:00
parent c38b2e9ba6
commit ccf368b4d8
6 changed files with 427 additions and 3 deletions
@@ -1,6 +1,6 @@
---
name: plugin-creator
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, generate or update marketplace entries for plugin ordering and availability metadata, or update an existing local plugin during development with the CLI-driven cachebuster and reinstall flow.
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional components such as skills, hooks, apps, MCP servers, Scheduled tasks, scripts, and assets, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, generate or update marketplace entries, or update an existing local plugin with the cachebuster and reinstall flow.
---
# Plugin Creator
@@ -56,7 +56,8 @@ On Windows, use the equivalent path under the user profile.
python3 scripts/create_basic_plugin.py my-plugin \
--path <parent-plugin-directory> \
--marketplace-path <marketplace-json-path> \
--with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps --with-marketplace
--with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps \
--with-scheduled --with-marketplace
```
`<parent-plugin-directory>` is the directory where the plugin folder `<plugin-name>` will be
@@ -98,6 +99,7 @@ See `references/installing-and-updating.md` for the expected cachebuster and rei
- `hooks/`
- `scripts/`
- `assets/`
- `scheduled/`
- `.mcp.json`
- `.app.json`
@@ -186,6 +188,8 @@ See `references/installing-and-updating.md` for the expected cachebuster and rei
- Do not remove required structure; keep `.codex-plugin/plugin.json` present.
- Do not leave `[TODO: ...]` placeholders in plugin manifests.
- Keep `apps` and `mcpServers` out of `plugin.json` unless their companion files are actually created.
- Keep Scheduled task templates under `scheduled/`; do not declare them in `plugin.json`. Follow
`references/plugin-json-spec.md` for their schema and authoring guidance.
- Omit unsupported plugin manifest fields that validation rejects, including `hooks`.
- If creating files inside an existing plugin path, use `--force` only when overwrite is intentional.
- Preserve any existing marketplace `interface.displayName`.
@@ -222,7 +226,7 @@ See `references/installing-and-updating.md` for the expected cachebuster and rei
## Reference to exact spec sample
For the exact canonical sample JSON for both plugin manifests and marketplace entries, use:
For exact JSON contracts and authoring guidance, use:
- `references/plugin-json-spec.md`
- `references/installing-and-updating.md` for update/reinstall guidance while
@@ -118,6 +118,91 @@ Or as an object directly in `plugin.json`:
- Custom path values must follow the plugin root convention and naming/namespacing rules.
- This repo’s scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates.
# Scheduled Task Template JSON sample spec
Plugins can bundle recipes for recurring Codex work under the reserved `scheduled/` root:
```text
my-plugin/
├── .codex-plugin/
│ └── plugin.json
├── skills/
└── scheduled/
├── daily-review.json
└── weekly-triage.json
```
Do not add a `scheduled` field to `plugin.json`. Supporting Codex clients discover each
`scheduled/<template-key>.json` file by convention. The filename stem is the template key and must
be lowercase kebab case.
Every template contains exactly `name`, `prompt`, and `schedule`:
```json
{
"name": "Morning inbox triage",
"prompt": "Review my inbox since the previous workday and surface messages that need attention, with a prioritized next-action list.",
"schedule": {
"type": "weekdays",
"time": "08:30"
}
}
```
Supported schedule shapes are:
```json
{
"type": "hourly",
"intervalHours": 2,
"days": ["MO", "TU", "WE", "TH", "FR"]
}
```
`days` is optional for hourly schedules; omitting it runs on every day. `intervalHours` must be a
positive integer.
```json
{ "type": "daily", "time": "09:00" }
```
```json
{ "type": "weekdays", "time": "09:00" }
```
```json
{
"type": "weekly",
"days": ["TU", "TH"],
"time": "16:45"
}
```
Times use local wall-clock `HH:MM` in 24-hour format from `00:00` through `23:59`. Every `days`
array must be a nonempty, unique subset of `MO`, `TU`, `WE`, `TH`, `FR`, `SA`, and `SU`.
### Authoring guidance
- Choose a task that genuinely repeats and can be completed with the plugin's capabilities.
- Confirm its cadence, source scope, expected output, and whether it may make changes.
- Prefer one plugin's natural workflow. Use a purpose-built plugin when the recurring workflow
intentionally coordinates multiple connectors.
- Keep the default prompt concise but useful before personalization. Include the source and time
window, what deserves attention, the expected output, and an explicit read-only boundary when
appropriate.
- Avoid personal account identifiers, team names, destinations, and project-specific assumptions
that belong in the user's customized instance.
- Write real templates rather than placeholders, then run
`scripts/validate_plugin.py <plugin-path>`. Strict JSON, valid filenames, and known fields matter
because Codex silently omits invalid templates.
- When adding templates to an existing installed plugin, use the cachebuster and reinstall flow so
Codex reads the updated materialized copy.
Installing a plugin does not activate its templates. The user reviews a template and explicitly
creates an ordinary user-owned Scheduled task from it. Codex does not retain a live relationship to
the plugin or template, so plugin updates affect future creations only. Describe these as Codex
Scheduled task templates; do not promise availability as ChatGPT or cloud automations.
# Marketplace JSON sample spec
`marketplace.json` depends on where the plugin should live. New plugin creation defaults to the
@@ -207,6 +207,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory")
parser.add_argument("--with-scripts", action="store_true", help="Create scripts/ directory")
parser.add_argument("--with-assets", action="store_true", help="Create assets/ directory")
parser.add_argument(
"--with-scheduled",
action="store_true",
help="Create scheduled/ directory for Scheduled task template JSON files",
)
parser.add_argument("--with-mcp", action="store_true", help="Create .mcp.json placeholder")
parser.add_argument("--with-apps", action="store_true", help="Create .app.json placeholder")
parser.add_argument(
@@ -281,6 +286,7 @@ def main() -> None:
"hooks": args.with_hooks,
"scripts": args.with_scripts,
"assets": args.with_assets,
"scheduled": args.with_scheduled,
}
for folder, enabled in optional_directories.items():
if enabled:
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Tests for plugin Scheduled task scaffolding and validation."""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from create_basic_plugin import build_plugin_json # noqa: E402
from validate_plugin import validate_plugin # noqa: E402
from validate_scheduled_tasks import validate_scheduled_task_templates # noqa: E402
class ScheduledTaskTemplateValidationTest(unittest.TestCase):
def test_accepts_every_schedule_shape(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugin_root = Path(temp_dir)
templates = {
"hourly.json": {
"type": "hourly",
"intervalHours": 2.0,
"days": ["MO", "WE", "FR"],
},
"daily.json": {"type": "daily", "time": "07:05"},
"weekdays.json": {"type": "weekdays", "time": "09:00"},
"weekly.json": {
"type": "weekly",
"days": ["TU", "SU"],
"time": "23:59",
},
}
for filename, schedule in templates.items():
self._write_template(plugin_root, filename, schedule=schedule)
self.assertEqual(validate_scheduled_task_templates(plugin_root), [])
def test_reports_templates_codex_would_silently_omit(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugin_root = Path(temp_dir)
self._write_template(
plugin_root,
"Daily.JSON",
schedule={"type": "daily", "time": "09:00"},
)
self._write_template(
plugin_root,
"bad-hourly.json",
name="",
prompt=" ",
extra=True,
schedule={
"type": "hourly",
"intervalHours": True,
"days": ["MO", "MO"],
"time": "09:00",
},
)
broken_path = plugin_root / "scheduled" / "broken.json"
broken_path.write_text("not-json", encoding="utf-8")
self.assertEqual(
validate_scheduled_task_templates(plugin_root),
[
"`scheduled/Daily.JSON` filename must be lowercase kebab-case ending in `.json`",
"`scheduled/bad-hourly.json` field `extra` is not supported",
"`scheduled/bad-hourly.json` field `name` must be a non-empty string",
"`scheduled/bad-hourly.json` field `prompt` must be a non-empty string",
"`scheduled/bad-hourly.json` field `schedule.time` is not supported",
"`scheduled/bad-hourly.json` field `schedule.intervalHours` must be a positive integer",
"`scheduled/bad-hourly.json` field `schedule.days` must contain unique weekdays from `MO` through `SU`",
"`scheduled/broken.json` must contain strict standard JSON",
],
)
def test_plugin_validator_includes_scheduled_task_errors(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugin_root = Path(temp_dir) / "reporting"
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
manifest_path.parent.mkdir(parents=True)
manifest_path.write_text(
json.dumps(
build_plugin_json(
"reporting",
with_mcp=False,
with_apps=False,
)
),
encoding="utf-8",
)
self._write_template(
plugin_root,
"report.json",
schedule={"type": "daily", "time": "9:00"},
)
self.assertEqual(
validate_plugin(plugin_root),
[
"`scheduled/report.json` field `schedule.time` must use 24-hour `HH:MM` format"
],
)
def test_scaffold_can_create_an_empty_scheduled_directory(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
script_path = Path(__file__).with_name("create_basic_plugin.py")
subprocess.run(
[
sys.executable,
str(script_path),
"reporting",
"--path",
temp_dir,
"--with-scheduled",
],
check=True,
capture_output=True,
text=True,
)
scheduled_root = Path(temp_dir) / "reporting" / "scheduled"
self.assertTrue(scheduled_root.is_dir())
self.assertEqual(list(scheduled_root.iterdir()), [])
def _write_template(
self,
plugin_root: Path,
filename: str,
*,
name: str = "Report",
prompt: str = "Summarize the queue.",
schedule: dict[str, object],
**extra: object,
) -> None:
scheduled_root = plugin_root / "scheduled"
scheduled_root.mkdir(parents=True, exist_ok=True)
payload = {
"name": name,
"prompt": prompt,
"schedule": schedule,
**extra,
}
(scheduled_root / filename).write_text(
json.dumps(payload),
encoding="utf-8",
)
if __name__ == "__main__":
unittest.main()
@@ -6,6 +6,7 @@ from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlparse
@@ -13,6 +14,13 @@ from urllib.parse import urlparse
import yaml
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from validate_scheduled_tasks import validate_scheduled_task_templates # noqa: E402
TODO_MARKER = "[TODO:"
SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\."
@@ -52,6 +60,7 @@ def validate_plugin(plugin_root: Path) -> list[str]:
reject_todo_markers(manifest, "$", errors)
validate_manifest_shape(plugin_root, manifest, errors)
errors.extend(validate_scheduled_task_templates(plugin_root))
return errors
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Validate plugin Scheduled task templates against the Codex JSON contract."""
from __future__ import annotations
import json
import math
import re
from pathlib import Path
from typing import Any
TEMPLATE_FILENAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.json$")
TIME_RE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
WEEKDAYS = {"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
TEMPLATE_FIELDS = {"name", "prompt", "schedule"}
def validate_scheduled_task_templates(plugin_root: Path) -> list[str]:
"""Return author-facing errors for templates that Codex would silently omit."""
scheduled_root = plugin_root / "scheduled"
if not scheduled_root.exists():
return []
if not scheduled_root.is_dir():
return ["`scheduled` must be a directory"]
errors: list[str] = []
for template_path in sorted(scheduled_root.iterdir()):
if not template_path.is_file() or not template_path.name.lower().endswith(
".json"
):
continue
_validate_template_file(plugin_root, template_path, errors)
return errors
def _validate_template_file(
plugin_root: Path,
template_path: Path,
errors: list[str],
) -> None:
relative_path = template_path.relative_to(plugin_root).as_posix()
if TEMPLATE_FILENAME_RE.fullmatch(template_path.name) is None:
errors.append(
f"`{relative_path}` filename must be lowercase kebab-case ending in `.json`"
)
try:
payload = json.loads(template_path.read_text(encoding="utf-8"))
except OSError:
errors.append(f"unable to read `{relative_path}`")
return
except json.JSONDecodeError:
errors.append(f"`{relative_path}` must contain strict standard JSON")
return
if not isinstance(payload, dict):
errors.append(f"`{relative_path}` must contain a JSON object")
return
_reject_unknown_fields(payload, TEMPLATE_FIELDS, relative_path, "$", errors)
_require_non_empty_string(payload, "name", relative_path, errors)
_require_non_empty_string(payload, "prompt", relative_path, errors)
_validate_schedule(payload.get("schedule"), relative_path, errors)
def _validate_schedule(
schedule: Any,
relative_path: str,
errors: list[str],
) -> None:
if not isinstance(schedule, dict):
errors.append(f"`{relative_path}` field `schedule` must be an object")
return
schedule_type = schedule.get("type")
if schedule_type == "hourly":
allowed_fields = {"type", "intervalHours", "days"}
_reject_unknown_fields(
schedule, allowed_fields, relative_path, "schedule", errors
)
interval = schedule.get("intervalHours")
if not _is_positive_integer(interval):
errors.append(
f"`{relative_path}` field `schedule.intervalHours` must be a positive integer"
)
if "days" in schedule:
_validate_days(schedule["days"], relative_path, errors)
return
if schedule_type in {"daily", "weekdays"}:
_reject_unknown_fields(
schedule, {"type", "time"}, relative_path, "schedule", errors
)
_validate_time(schedule.get("time"), relative_path, errors)
return
if schedule_type == "weekly":
_reject_unknown_fields(
schedule,
{"type", "days", "time"},
relative_path,
"schedule",
errors,
)
_validate_days(schedule.get("days"), relative_path, errors)
_validate_time(schedule.get("time"), relative_path, errors)
return
errors.append(
f"`{relative_path}` field `schedule.type` must be `hourly`, `daily`, "
"`weekdays`, or `weekly`"
)
def _validate_time(value: Any, relative_path: str, errors: list[str]) -> None:
if not isinstance(value, str) or TIME_RE.fullmatch(value) is None:
errors.append(
f"`{relative_path}` field `schedule.time` must use 24-hour `HH:MM` format"
)
def _is_positive_integer(value: Any) -> bool:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False
return math.isfinite(value) and value > 0 and float(value).is_integer()
def _validate_days(value: Any, relative_path: str, errors: list[str]) -> None:
if (
not isinstance(value, list)
or not value
or any(not isinstance(day, str) or day not in WEEKDAYS for day in value)
or len(set(value)) != len(value)
):
errors.append(
f"`{relative_path}` field `schedule.days` must contain unique weekdays "
"from `MO` through `SU`"
)
def _require_non_empty_string(
payload: dict[str, Any],
field: str,
relative_path: str,
errors: list[str],
) -> None:
value = payload.get(field)
if not isinstance(value, str) or not value.strip():
errors.append(f"`{relative_path}` field `{field}` must be a non-empty string")
def _reject_unknown_fields(
payload: dict[str, Any],
allowed_fields: set[str],
relative_path: str,
prefix: str,
errors: list[str],
) -> None:
for field in sorted(set(payload) - allowed_fields):
field_path = field if prefix == "$" else f"{prefix}.{field}"
errors.append(f"`{relative_path}` field `{field_path}` is not supported")