Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ JavaClaw is a Java-based personal AI assistant that runs on your own devices. It

- **Multi-Channel Support** — Chat UI (WebSocket), Telegram, Discord, and an extensible plugin-based channel architecture
- **Task Management** — Create, schedule (one-off, delayed, or recurring via cron), and track tasks as human-readable Markdown files
- **Extensible Skills** — Drop a `SKILL.md` into `workspace/skills/` and the agent picks it up at runtime
- **Extensible Skills** — Load skills from `workspace/skills/` and from `skills jar` packages on the classpath
- **LLM Provider Choice** — Plug in OpenAI, Anthropic, or Ollama (local); switchable during onboarding
- **MCP Support** — Model Context Protocol client for connecting external tool servers
- **Shell & File Access** — Agent can read/write files and run bash commands on your machine
Expand Down Expand Up @@ -130,6 +130,44 @@ agent:

Skills extend the agent's capabilities at runtime without code changes. Create a directory under `workspace/skills/<skill-name>/` containing a `SKILL.md` file and the agent will load it automatically via `SkillsTool`.

### Skills As Jars (SkillsJars)

You can also package skills into a jar (or use a prebuilt one) and put it on the runtime classpath. JavaClaw can load skills from `classpath:/META-INF/skills`.

1. Add a dependency that contains the skills (example):

```gradle
dependencies {
runtimeOnly("com.skillsjars:some-skill-pack:VERSION")
}
```

2. Configure the classpath scan path:

```yaml
agent:
skills:
paths: classpath:/META-INF/skills
```

3. Ensure the jar contains this layout:

```text
META-INF/skills/<skill-name>/SKILL.md
```

`SKILL.md` must include YAML frontmatter with a `name` (this is what you invoke):

```md
---
name: <skill-name>
description: <optional>
---
<instructions...>
```

See the SkillsJars docs for packaging and published skill packs: https://www.skillsjars.com/

## Dashboard

JobRunr's job dashboard is available at [http://localhost:8081](http://localhost:8081) for monitoring background task execution.
Expand All @@ -142,6 +180,7 @@ Key properties in `application.yaml`:
|---|---|
| `agent.workspace` | Path to the workspace root (default: `file:./workspace/`) |
| `agent.onboarding.completed` | Set to `true` after onboarding is done |
| `agent.skills.paths` | Classpath resource roots to scan for skills (e.g. `classpath:/META-INF/skills`) |
| `spring.ai.model.chat` | Active LLM provider/model |
| `jobrunr.dashboard.port` | JobRunr dashboard port (default: `8081`) |
| `jobrunr.background-job-server.worker-count` | Concurrent job workers (default: `1`) |
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'

testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

}

bootRun {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ agent:
onboarding:
completed: false
workspace: file:./workspace/
skills:
paths: classpath:/META-INF/skills
jobrunr:
background-job-server:
enabled: true
Expand Down
1 change: 1 addition & 0 deletions base/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id 'java-library'
id("com.skillsjars.gradle-plugin") version "0.0.2"
}

dependencies {
Expand Down
10 changes: 9 additions & 1 deletion base/src/main/java/ai/javaclaw/JavaClawConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ public class JavaClawConfiguration {

public static final String AGENT_MD = "AGENT.private.md";

@Value("${agent.skills.paths}")
List<Resource> skillPaths;


@Bean
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = "unknown", matchIfMissing = true)
public ChatModel chatModel() {
Expand Down Expand Up @@ -87,7 +91,11 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
.defaultAdvisors(new SimpleLoggerAdvisor())
.defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info()))
.defaultToolCallbacks(mcpToolProvider.getToolCallbacks())
.defaultToolCallbacks(SkillsTool.builder().addSkillsDirectory(skillsDir(workspace).toString()).build())
.defaultToolCallbacks(SkillsTool.builder()
.addSkillsDirectory(skillsDir(workspace).toString())
.addSkillsResources(skillPaths)
.build()
)
.defaultTools(
TaskTool.builder().taskManager(taskManager).build(),
CheckListTool.builder().build(),
Expand Down
74 changes: 74 additions & 0 deletions base/src/test/java/ai/javaclaw/tools/SkillsJarSupportTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package ai.javaclaw.tools;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springaicommunity.agent.tools.SkillsTool;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.core.io.ClassPathResource;

import java.io.OutputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;

import static org.assertj.core.api.Assertions.assertThat;

class SkillsJarSupportTest {

@TempDir
Path tempDir;

@Test
void loadsSkillsFromJarResource() throws Exception {
Path jarPath = tempDir.resolve("skills.jar");
writeSkillJar(jarPath);

URL jarUrl = jarPath.toUri().toURL();
ClassLoader originalCl = Thread.currentThread().getContextClassLoader();
try (URLClassLoader cl = new URLClassLoader(new URL[]{jarUrl}, originalCl)) {
Thread.currentThread().setContextClassLoader(cl);

ToolCallback callback = SkillsTool.builder()
.addSkillsResource(new ClassPathResource("META-INF/skills", cl))
.build();

String result = callback.call("{\"command\":\"jar-skill\"}");
assertThat(result).contains("Base directory for this skill:");
assertThat(result).contains("This skill came from a jar.");
} finally {
Thread.currentThread().setContextClassLoader(originalCl);
}
}

private static void writeSkillJar(Path jarPath) throws Exception {
Files.createDirectories(jarPath.getParent());
try (OutputStream out = Files.newOutputStream(jarPath);
JarOutputStream jar = new JarOutputStream(out, manifest())) {
JarEntry entry = new JarEntry("META-INF/skills/jar-skill/SKILL.md");
jar.putNextEntry(entry);
jar.write(skillMarkdown().getBytes(StandardCharsets.UTF_8));
jar.closeEntry();
}
}

private static Manifest manifest() {
Manifest manifest = new Manifest();
manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
return manifest;
}

private static String skillMarkdown() {
return """
---
name: jar-skill
description: Test skill packaged in a jar
---
This skill came from a jar.
""";
}
}
Loading